diff options
51 files changed, 993 insertions, 337 deletions
diff --git a/.gitlab/changelog_config.yml b/.gitlab/changelog_config.yml index 6069cd17a08..f6a041cced9 100644 --- a/.gitlab/changelog_config.yml +++ b/.gitlab/changelog_config.yml @@ -11,6 +11,8 @@ categories: security: Security performance: Performance other: Other +include_groups: + - gitlab-org/gitlab-core-team/community-members template: | {% if categories %} {% each categories %} @@ -18,7 +20,7 @@ template: | {% each entries %} - [{{ title }}]({{ commit.reference }})\ - {% if author.contributor %} by {{ author.reference }}{% end %}\ + {% if author.credit %} by {{ author.reference }}{% end %}\ {% if commit.trailers.MR %}\ ([merge request]({{ commit.trailers.MR }}))\ {% else %}\ diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 09aa4471a4c..b329c9df0f9 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -315,7 +315,6 @@ Performance/MethodObjectAsBlock: # Configuration parameters: AutoCorrect. Performance/StringInclude: Exclude: - - 'app/helpers/groups_helper.rb' - 'app/models/snippet_repository.rb' - 'config/initializers/macos.rb' - 'config/spring.rb' diff --git a/app/assets/javascripts/groups/components/invite_members_banner.vue b/app/assets/javascripts/groups/components/invite_members_banner.vue index 402d9a07c53..dfc1549fb4a 100644 --- a/app/assets/javascripts/groups/components/invite_members_banner.vue +++ b/app/assets/javascripts/groups/components/invite_members_banner.vue @@ -1,7 +1,7 @@ <script> import { GlBanner } from '@gitlab/ui'; import eventHub from '~/invite_members/event_hub'; -import { parseBoolean, setCookie, getCookie } from '~/lib/utils/common_utils'; +import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; @@ -12,10 +12,10 @@ export default { GlBanner, }, mixins: [trackingMixin], - inject: ['svgPath', 'isDismissedKey', 'trackLabel'], + inject: ['svgPath', 'trackLabel', 'calloutsPath', 'calloutsFeatureId', 'groupId'], data() { return { - isDismissed: parseBoolean(getCookie(this.isDismissedKey)), + isDismissed: false, tracking: { label: this.trackLabel, }, @@ -26,7 +26,16 @@ export default { }, methods: { handleClose() { - setCookie(this.isDismissedKey, true); + axios + .post(this.calloutsPath, { + feature_name: this.calloutsFeatureId, + group_id: this.groupId, + }) + .catch((e) => { + // eslint-disable-next-line @gitlab/require-i18n-strings, no-console + console.error('Failed to dismiss banner.', e); + }); + this.isDismissed = true; this.track(this.$options.dismissEvent); }, @@ -61,6 +70,7 @@ export default { <gl-banner v-if="!isDismissed" ref="banner" + data-testid="invite-members-banner" :title="$options.i18n.title" :button-text="$options.i18n.button_text" :svg-path="svgPath" diff --git a/app/assets/javascripts/groups/init_invite_members_banner.js b/app/assets/javascripts/groups/init_invite_members_banner.js index 2052dd6ac8c..38ab4122dab 100644 --- a/app/assets/javascripts/groups/init_invite_members_banner.js +++ b/app/assets/javascripts/groups/init_invite_members_banner.js @@ -8,15 +8,24 @@ export default function initInviteMembersBanner() { return false; } - const { svgPath, inviteMembersPath, isDismissedKey, trackLabel } = el.dataset; + const { + svgPath, + inviteMembersPath, + trackLabel, + calloutsPath, + calloutsFeatureId, + groupId, + } = el.dataset; return new Vue({ el, provide: { svgPath, inviteMembersPath, - isDismissedKey, trackLabel, + calloutsPath, + calloutsFeatureId, + groupId, }, render: (createElement) => createElement(InviteMembersBanner), }); diff --git a/app/controllers/user_callouts_controller.rb b/app/controllers/user_callouts_controller.rb index df3e2425e9f..f52a09adf5a 100644 --- a/app/controllers/user_callouts_controller.rb +++ b/app/controllers/user_callouts_controller.rb @@ -4,10 +4,6 @@ class UserCalloutsController < ApplicationController feature_category :navigation def create - callout = Users::DismissUserCalloutService.new( - container: nil, current_user: current_user, params: { feature_name: feature_name } - ).execute - if callout.persisted? respond_to do |format| format.json { head :ok } @@ -21,6 +17,12 @@ class UserCalloutsController < ApplicationController private + def callout + Users::DismissUserCalloutService.new( + container: nil, current_user: current_user, params: { feature_name: feature_name } + ).execute + end + def feature_name params.require(:feature_name) end diff --git a/app/controllers/users/group_callouts_controller.rb b/app/controllers/users/group_callouts_controller.rb new file mode 100644 index 00000000000..cc27452e6a3 --- /dev/null +++ b/app/controllers/users/group_callouts_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Users + class GroupCalloutsController < UserCalloutsController + private + + def callout + Users::DismissGroupCalloutService.new( + container: nil, current_user: current_user, params: callout_params + ).execute + end + + def callout_params + params.permit(:group_id).merge(feature_name: feature_name) + end + end +end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index ef47365648b..a24776eb2e4 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -122,12 +122,6 @@ module GroupsHelper groups.to_json end - def show_invite_banner?(group) - can?(current_user, :admin_group, group) && - !just_created? && - !multiple_members?(group) - end - def render_setting_to_allow_project_access_token_creation?(group) group.root? && current_user.can?(:admin_setting_to_allow_project_access_token_creation, group) end @@ -142,14 +136,6 @@ module GroupsHelper private - def just_created? - flash[:notice] =~ /successfully created/ - end - - def multiple_members?(group) - group.member_count > 1 || group.members_with_parents.count > 1 - end - def group_title_link(group, hidable: false, show_avatar: false, for_dropdown: false) link_to(group_path(group), class: "group-path #{'breadcrumb-item-text' unless for_dropdown} js-breadcrumb-item-text #{'hidable' if hidable}") do icon = group_icon(group, class: "avatar-tile", width: 15, height: 15) if (group.try(:avatar_url) || show_avatar) && !Rails.env.test? diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb index 76b3edbf168..2c3dc243d85 100644 --- a/app/helpers/user_callouts_helper.rb +++ b/app/helpers/user_callouts_helper.rb @@ -9,6 +9,7 @@ module UserCalloutsHelper FEATURE_FLAGS_NEW_VERSION = 'feature_flags_new_version' REGISTRATION_ENABLED_CALLOUT = 'registration_enabled_callout' UNFINISHED_TAG_CLEANUP_CALLOUT = 'unfinished_tag_cleanup_callout' + INVITE_MEMBERS_BANNER = 'invite_members_banner' def show_gke_cluster_integration_callout?(project) active_nav_link?(controller: sidebar_operations_paths) && @@ -56,6 +57,13 @@ module UserCalloutsHelper def dismiss_two_factor_auth_recovery_settings_check end + def show_invite_banner?(group) + Ability.allowed?(current_user, :admin_group, group) && + !just_created? && + !user_dismissed_for_group(INVITE_MEMBERS_BANNER, group) && + !multiple_members?(group) + end + private def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil) @@ -63,6 +71,43 @@ module UserCalloutsHelper current_user.dismissed_callout?(feature_name: feature_name, ignore_dismissal_earlier_than: ignore_dismissal_earlier_than) end + + def user_dismissed_for_group(feature_name, group, ignore_dismissal_earlier_than = nil) + return false unless current_user + + set_dismissed_from_cookie(group) + + current_user.dismissed_callout_for_group?(feature_name: feature_name, + group: group, + ignore_dismissal_earlier_than: ignore_dismissal_earlier_than) + end + + def set_dismissed_from_cookie(group) + # bridge function for one milestone to try and not annoy users who might have already dismissed this alert + # remove in 14.4 or 14.5? https://gitlab.com/gitlab-org/gitlab/-/issues/340322 + dismissed_key = "invite_#{group.id}_#{current_user.id}" + + if cookies[dismissed_key].present? + params = { + feature_name: INVITE_MEMBERS_BANNER, + group_id: group.id + } + + Users::DismissGroupCalloutService.new( + container: nil, current_user: current_user, params: params + ).execute + + cookies.delete dismissed_key + end + end + + def just_created? + flash[:notice]&.include?('successfully created') + end + + def multiple_members?(group) + group.member_count > 1 || group.members_with_parents.count > 1 + end end UserCalloutsHelper.prepend_mod diff --git a/app/models/concerns/calloutable.rb b/app/models/concerns/calloutable.rb new file mode 100644 index 00000000000..8b9cfae6a32 --- /dev/null +++ b/app/models/concerns/calloutable.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Calloutable + extend ActiveSupport::Concern + + included do + belongs_to :user + + validates :user, presence: true + end + + def dismissed_after?(dismissed_after) + dismissed_at > dismissed_after + end +end diff --git a/app/models/group.rb b/app/models/group.rb index 3e1b40e21bd..437c750afa6 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -85,6 +85,8 @@ class Group < Namespace # debian_distributions and associated component_files must be destroyed by ruby code in order to properly remove carrierwave uploads has_many :debian_distributions, class_name: 'Packages::Debian::GroupDistribution', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :group_callouts, class_name: 'Users::GroupCallout', foreign_key: :group_id + delegate :prevent_sharing_groups_outside_hierarchy, :new_user_signups_cap, :setup_for_company, :jobs_to_be_done, to: :namespace_settings accepts_nested_attributes_for :variables, allow_destroy: true diff --git a/app/models/user.rb b/app/models/user.rb index 79df91e3d5f..c1ef8229898 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -200,6 +200,7 @@ class User < ApplicationRecord has_many :custom_attributes, class_name: 'UserCustomAttribute' has_many :callouts, class_name: 'UserCallout' + has_many :group_callouts, class_name: 'Users::GroupCallout' has_many :term_agreements belongs_to :accepted_term, class_name: 'ApplicationSetting::Term' @@ -1928,10 +1929,14 @@ class User < ApplicationRecord def dismissed_callout?(feature_name:, ignore_dismissal_earlier_than: nil) callout = callouts_by_feature_name[feature_name] - return false unless callout - return callout.dismissed_after?(ignore_dismissal_earlier_than) if ignore_dismissal_earlier_than + callout_dismissed?(callout, ignore_dismissal_earlier_than) + end - true + def dismissed_callout_for_group?(feature_name:, group:, ignore_dismissal_earlier_than: nil) + source_feature_name = "#{feature_name}_#{group.id}" + callout = group_callouts_by_feature_name[source_feature_name] + + callout_dismissed?(callout, ignore_dismissal_earlier_than) end # Load the current highest access by looking directly at the user's memberships @@ -1955,6 +1960,11 @@ class User < ApplicationRecord callouts.find_or_initialize_by(feature_name: ::UserCallout.feature_names[feature_name]) end + def find_or_initialize_group_callout(feature_name, group_id) + group_callouts + .find_or_initialize_by(feature_name: ::Users::GroupCallout.feature_names[feature_name], group_id: group_id) + end + def can_trigger_notifications? confirmed? && !blocked? && !ghost? end @@ -2026,10 +2036,21 @@ class User < ApplicationRecord errors.add(:commit_email, _("must be an email you have verified")) unless verified_emails.include?(commit_email) end + def callout_dismissed?(callout, ignore_dismissal_earlier_than) + return false unless callout + return callout.dismissed_after?(ignore_dismissal_earlier_than) if ignore_dismissal_earlier_than + + true + end + def callouts_by_feature_name @callouts_by_feature_name ||= callouts.index_by(&:feature_name) end + def group_callouts_by_feature_name + @group_callouts_by_feature_name ||= group_callouts.index_by(&:source_feature_name) + end + def authorized_groups_without_shared_membership Group.from_union([ groups.select(Namespace.arel_table[Arel.star]), diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb index 32f84bb95e8..04bc29755f8 100644 --- a/app/models/user_callout.rb +++ b/app/models/user_callout.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class UserCallout < ApplicationRecord - belongs_to :user + include Calloutable enum feature_name: { gke_cluster_integration: 1, @@ -39,13 +39,8 @@ class UserCallout < ApplicationRecord terraform_notification_dismissed: 38 } - validates :user, presence: true validates :feature_name, presence: true, uniqueness: { scope: :user_id }, inclusion: { in: UserCallout.feature_names.keys } - - def dismissed_after?(dismissed_after) - dismissed_at > dismissed_after - end end diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb new file mode 100644 index 00000000000..540d1a1d242 --- /dev/null +++ b/app/models/users/group_callout.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Users + class GroupCallout < ApplicationRecord + include Calloutable + + self.table_name = 'user_group_callouts' + + belongs_to :group + + enum feature_name: { + invite_members_banner: 1 + } + + validates :group, presence: true + validates :feature_name, + presence: true, + uniqueness: { scope: [:user_id, :group_id] }, + inclusion: { in: GroupCallout.feature_names.keys } + + def source_feature_name + "#{feature_name}_#{group_id}" + end + end +end diff --git a/app/services/repositories/changelog_service.rb b/app/services/repositories/changelog_service.rb index bac3fdf36da..96db00fbc1b 100644 --- a/app/services/repositories/changelog_service.rb +++ b/app/services/repositories/changelog_service.rb @@ -61,7 +61,7 @@ module Repositories # rubocop: enable Metrics/ParameterLists def execute - config = Gitlab::Changelog::Config.from_git(@project) + config = Gitlab::Changelog::Config.from_git(@project, @user) from = start_of_commit_range(config) # For every entry we want to only include the merge request that diff --git a/app/services/users/dismiss_group_callout_service.rb b/app/services/users/dismiss_group_callout_service.rb new file mode 100644 index 00000000000..8afee6a8187 --- /dev/null +++ b/app/services/users/dismiss_group_callout_service.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Users + class DismissGroupCalloutService < DismissUserCalloutService + private + + def callout + current_user.find_or_initialize_group_callout(params[:feature_name], params[:group_id]) + end + end +end diff --git a/app/services/users/dismiss_user_callout_service.rb b/app/services/users/dismiss_user_callout_service.rb index f05c44186bb..96f3f3acb57 100644 --- a/app/services/users/dismiss_user_callout_service.rb +++ b/app/services/users/dismiss_user_callout_service.rb @@ -3,9 +3,15 @@ module Users class DismissUserCalloutService < BaseContainerService def execute - current_user.find_or_initialize_callout(params[:feature_name]).tap do |callout| - callout.update(dismissed_at: Time.current) if callout.valid? + callout.tap do |record| + record.update(dismissed_at: Time.current) if record.valid? end end + + private + + def callout + current_user.find_or_initialize_callout(params[:feature_name]) + end end end diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 76850f0a884..2e74d983397 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -12,9 +12,11 @@ = content_for :group_invite_members_banner do .container-fluid.container-limited{ class: "gl-pb-2! gl-pt-6! #{@content_class}" } .js-group-invite-members-banner{ data: { svg_path: image_path('illustrations/merge_requests.svg'), - is_dismissed_key: "invite_#{@group.id}_#{current_user.id}", track_label: 'invite_members_banner', - invite_members_path: group_group_members_path(@group) } } + invite_members_path: group_group_members_path(@group), + callouts_path: group_callouts_path, + callouts_feature_id: UserCalloutsHelper::INVITE_MEMBERS_BANNER, + group_id: @group.id } } = render 'groups/invite_members_modal', group: @group = content_for :meta_tags do diff --git a/config/routes/user.rb b/config/routes/user.rb index 7d61aa089fb..01de59c3357 100644 --- a/config/routes/user.rb +++ b/config/routes/user.rb @@ -21,11 +21,35 @@ if Gitlab::Auth::Ldap::Config.sign_in_enabled? end end -devise_for :users, controllers: { omniauth_callbacks: :omniauth_callbacks, - registrations: :registrations, - passwords: :passwords, - sessions: :sessions, - confirmations: :confirmations } +devise_controllers = { omniauth_callbacks: :omniauth_callbacks, + registrations: :registrations, + passwords: :passwords, + sessions: :sessions, + confirmations: :confirmations } + +if ::Gitlab.ee? && ::Gitlab::Geo.connected? && ::Gitlab::Geo.secondary? + devise_for :users, controllers: devise_controllers, path_names: { sign_in: 'auth/geo/sign_in', + sign_out: 'auth/geo/sign_out' } + # When using Geo, the other type of routes should be present as well, as browsers + # cache 302 redirects locally, and events like primary going offline or a failover + # can result in browsers requesting the other paths because of it. + as :user do + get '/users/sign_in', to: 'sessions#new' + post '/users/sign_in', to: 'sessions#create' + post '/users/sign_out', to: 'sessions#destroy' + end +else + devise_for :users, controllers: devise_controllers + + # We avoid drawing Geo routes for FOSS, but keep them in for EE + Gitlab.ee do + as :user do + get '/users/auth/geo/sign_in', to: 'sessions#new' + post '/users/auth/geo/sign_in', to: 'sessions#create' + post '/users/auth/geo/sign_out', to: 'sessions#destroy' + end + end +end devise_scope :user do get '/users/almost_there' => 'confirmations#almost_there' @@ -36,6 +60,8 @@ scope '-/users', module: :users do post :accept, on: :member post :decline, on: :member end + + resources :group_callouts, only: [:create] end scope(constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }) do diff --git a/db/migrate/20210823172643_create_user_group_callout.rb b/db/migrate/20210823172643_create_user_group_callout.rb new file mode 100644 index 00000000000..72341c0b275 --- /dev/null +++ b/db/migrate/20210823172643_create_user_group_callout.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreateUserGroupCallout < ActiveRecord::Migration[6.1] + def up + create_table :user_group_callouts do |t| + t.bigint :user_id, null: false + t.bigint :group_id, null: false + t.integer :feature_name, limit: 2, null: false + t.datetime_with_timezone :dismissed_at + + t.index :group_id + t.index [:user_id, :feature_name, :group_id], unique: true, name: 'index_group_user_callouts_feature' + end + end + + def down + drop_table :user_group_callouts + end +end diff --git a/db/migrate/20210907182337_add_group_id_fkey_for_user_group_callout.rb b/db/migrate/20210907182337_add_group_id_fkey_for_user_group_callout.rb new file mode 100644 index 00000000000..540344bd761 --- /dev/null +++ b/db/migrate/20210907182337_add_group_id_fkey_for_user_group_callout.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddGroupIdFkeyForUserGroupCallout < Gitlab::Database::Migration[1.0] + disable_ddl_transaction! + + def up + add_concurrent_foreign_key :user_group_callouts, :namespaces, column: :group_id, on_delete: :cascade + end + + def down + with_lock_retries do + remove_foreign_key :user_group_callouts, column: :group_id + end + end +end diff --git a/db/migrate/20210907182359_add_user_id_fkey_for_user_group_callout.rb b/db/migrate/20210907182359_add_user_id_fkey_for_user_group_callout.rb new file mode 100644 index 00000000000..37b73335933 --- /dev/null +++ b/db/migrate/20210907182359_add_user_id_fkey_for_user_group_callout.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddUserIdFkeyForUserGroupCallout < Gitlab::Database::Migration[1.0] + disable_ddl_transaction! + + def up + add_concurrent_foreign_key :user_group_callouts, :users, column: :user_id, on_delete: :cascade + end + + def down + with_lock_retries do + remove_foreign_key :user_group_callouts, column: :user_id + end + end +end diff --git a/db/schema_migrations/20210823172643 b/db/schema_migrations/20210823172643 new file mode 100644 index 00000000000..e89e11bb544 --- /dev/null +++ b/db/schema_migrations/20210823172643 @@ -0,0 +1 @@ +e6570f8ee366431b17b34051b9d0dcf2aff6216f8d65b3b6eec5be5666fed229
\ No newline at end of file diff --git a/db/schema_migrations/20210907182337 b/db/schema_migrations/20210907182337 new file mode 100644 index 00000000000..cc3d32067c2 --- /dev/null +++ b/db/schema_migrations/20210907182337 @@ -0,0 +1 @@ +ad564a1fda815473b09f1eda469e67cdd8f532b9b481f7e8ae3ddb8f2df6ee40
\ No newline at end of file diff --git a/db/schema_migrations/20210907182359 b/db/schema_migrations/20210907182359 new file mode 100644 index 00000000000..41e72e9dfec --- /dev/null +++ b/db/schema_migrations/20210907182359 @@ -0,0 +1 @@ +da57784c8c7f8bcb3c8c61089b5a695efdb31b209cb1616af68240380c734669
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 3f604828eed..34b7d38383f 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -19787,6 +19787,23 @@ CREATE TABLE user_follow_users ( followee_id integer NOT NULL ); +CREATE TABLE user_group_callouts ( + id bigint NOT NULL, + user_id bigint NOT NULL, + group_id bigint NOT NULL, + feature_name smallint NOT NULL, + dismissed_at timestamp with time zone +); + +CREATE SEQUENCE user_group_callouts_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE user_group_callouts_id_seq OWNED BY user_group_callouts.id; + CREATE TABLE user_highest_roles ( user_id bigint NOT NULL, updated_at timestamp with time zone NOT NULL, @@ -21697,6 +21714,8 @@ ALTER TABLE ONLY user_custom_attributes ALTER COLUMN id SET DEFAULT nextval('use ALTER TABLE ONLY user_details ALTER COLUMN user_id SET DEFAULT nextval('user_details_user_id_seq'::regclass); +ALTER TABLE ONLY user_group_callouts ALTER COLUMN id SET DEFAULT nextval('user_group_callouts_id_seq'::regclass); + ALTER TABLE ONLY user_permission_export_uploads ALTER COLUMN id SET DEFAULT nextval('user_permission_export_uploads_id_seq'::regclass); ALTER TABLE ONLY user_preferences ALTER COLUMN id SET DEFAULT nextval('user_preferences_id_seq'::regclass); @@ -23642,6 +23661,9 @@ ALTER TABLE ONLY user_details ALTER TABLE ONLY user_follow_users ADD CONSTRAINT user_follow_users_pkey PRIMARY KEY (follower_id, followee_id); +ALTER TABLE ONLY user_group_callouts + ADD CONSTRAINT user_group_callouts_pkey PRIMARY KEY (id); + ALTER TABLE ONLY user_highest_roles ADD CONSTRAINT user_highest_roles_pkey PRIMARY KEY (user_id); @@ -25197,6 +25219,8 @@ CREATE UNIQUE INDEX index_group_stages_on_group_id_group_value_stream_id_and_nam CREATE INDEX index_group_stages_on_stage_event_hash_id ON analytics_cycle_analytics_group_stages USING btree (stage_event_hash_id); +CREATE UNIQUE INDEX index_group_user_callouts_feature ON user_group_callouts USING btree (user_id, feature_name, group_id); + CREATE UNIQUE INDEX index_group_wiki_repositories_on_disk_path ON group_wiki_repositories USING btree (disk_path); CREATE INDEX index_group_wiki_repositories_on_shard_id ON group_wiki_repositories USING btree (shard_id); @@ -26591,6 +26615,8 @@ CREATE INDEX index_user_details_on_provisioned_by_group_id ON user_details USING CREATE UNIQUE INDEX index_user_details_on_user_id ON user_details USING btree (user_id); +CREATE INDEX index_user_group_callouts_on_group_id ON user_group_callouts USING btree (group_id); + CREATE INDEX index_user_highest_roles_on_user_id_and_highest_access_level ON user_highest_roles USING btree (user_id, highest_access_level); CREATE INDEX index_user_interacted_projects_on_user_id ON user_interacted_projects USING btree (user_id); @@ -27783,6 +27809,9 @@ ALTER TABLE ONLY issues ALTER TABLE ONLY epics ADD CONSTRAINT fk_9d480c64b2 FOREIGN KEY (start_date_sourcing_epic_id) REFERENCES epics(id) ON DELETE SET NULL; +ALTER TABLE ONLY user_group_callouts + ADD CONSTRAINT fk_9dc8b9d4b2 FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE; + ALTER TABLE ONLY protected_environments ADD CONSTRAINT fk_9e112565b7 FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE; @@ -27927,6 +27956,9 @@ ALTER TABLE ONLY geo_event_log ALTER TABLE ONLY analytics_cycle_analytics_project_stages ADD CONSTRAINT fk_c3339bdfc9 FOREIGN KEY (stage_event_hash_id) REFERENCES analytics_cycle_analytics_stage_event_hashes(id) ON DELETE CASCADE; +ALTER TABLE ONLY user_group_callouts + ADD CONSTRAINT fk_c366e12ec3 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE ONLY vulnerability_exports ADD CONSTRAINT fk_c3d3cb5d0f FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE; diff --git a/doc/administration/gitaly/praefect.md b/doc/administration/gitaly/praefect.md index ee3181cdf41..4ad9eb8c241 100644 --- a/doc/administration/gitaly/praefect.md +++ b/doc/administration/gitaly/praefect.md @@ -1575,3 +1575,28 @@ sudo /opt/gitlab/embedded/bin/praefect -config /var/opt/gitlab/praefect/config.t - Replace the placeholder `<virtual-storage>` with the virtual storage containing the Gitaly node storage to be checked. - Replace the placeholder `<up-to-date-storage>` with the Gitaly storage name containing up to date repositories. - Replace the placeholder `<outdated-storage>` with the Gitaly storage name containing outdated repositories. + +### Manually remove repositories + +> [Introduced](https://gitlab.com/gitlab-org/gitaly/-/merge_requests/3767) in GitLab 14.3. + +The `remove-repository` Praefect sub-command removes repositories from a Gitaly Cluster. It removes +all state associated with a given repository including: + +- On-disk repositories on all relevant Gitaly nodes. +- Any database state tracked by Praefect. + +```shell +sudo /opt/gitlab/embedded/bin/praefect -config /var/opt/gitlab/praefect/config.toml remove-repository -virtual-storage <virtual-storage> -repository <repository> +``` + +- `-virtual-storage` is the virtual storage the repository is located in. +- `-repository` is the repository's relative path in the storage. + +Sometimes parts of the repository continue to exist after running `remove-repository`. This can be caused +because of: + +- A deletion error. +- An in-flight RPC call targeting the repository. + +If this occurs, run `remove-repository` again. diff --git a/doc/api/repositories.md b/doc/api/repositories.md index a669bb5177f..1c9136d22ac 100644 --- a/doc/api/repositories.md +++ b/doc/api/repositories.md @@ -448,6 +448,10 @@ You can set the following variables in this file: - `template`: a custom template to use for generating the changelog data. - `categories`: a hash that maps raw category names to the names to use in the changelog. +- `include_groups`: a list of group full paths containing users whose + contributions should be credited regardless of project membership. The user + generating the changelog must have access to each group or the members will + not be credited. Using the default settings, generating a changelog results in a section along the lines of the following: @@ -508,7 +512,7 @@ follows: {% each entries %} - [{{ title }}]({{ commit.reference }})\ -{% if author.contributor %} by {{ author.reference }}{% end %}\ +{% if author.credit %} by {{ author.reference }}{% end %}\ {% if merge_request %} ([merge request]({{ merge_request.reference }})){% end %} {% end %} @@ -598,7 +602,7 @@ template: | {% each entries %} - [{{ title }}]({{ commit.reference }})\ - {% if author.contributor %} by {{ author.reference }}{% end %} + {% if author.credit %} by {{ author.reference }}{% end %} {% end %} @@ -634,8 +638,11 @@ In an entry, the following variables are available (here `foo.bar` means that - `commit.trailers`: an object containing all the Git trailers that were present in the commit body. - `author.reference`: a reference to the commit author (for example, `@alice`). -- `author.contributor`: a boolean set to `true` when the author is an external - contributor, otherwise this is set to `false`. +- `author.contributor`: a boolean set to `true` when the author is not a project + member, otherwise `false`. +- `author.credit`: a boolean set to `true` when `author.contributor` is `true` or + when `include_groups` is configured, and the author is a member of one of the + groups. - `merge_request.reference`: a reference to the merge request that first introduced the change (for example, `gitlab-org/gitlab!50063`). diff --git a/doc/user/project/merge_requests/test_coverage_visualization.md b/doc/user/project/merge_requests/test_coverage_visualization.md index 11360809ad7..813e3c1c9ce 100644 --- a/doc/user/project/merge_requests/test_coverage_visualization.md +++ b/doc/user/project/merge_requests/test_coverage_visualization.md @@ -24,7 +24,8 @@ Collecting the coverage information is done via GitLab CI/CD's [artifacts reports feature](../../../ci/yaml/index.md#artifactsreports). You can specify one or more coverage reports to collect, including wildcard paths. GitLab then takes the coverage information in all the files and combines it -together. +together. Coverage files are parsed in a background job so there can be a delay +between pipeline completion and the visualization loading on the page. For the coverage analysis to work, you have to provide a properly formatted [Cobertura XML](https://cobertura.github.io/cobertura/) report to diff --git a/lib/gitlab/changelog/config.rb b/lib/gitlab/changelog/config.rb index d25094d9b37..fd5d701b858 100644 --- a/lib/gitlab/changelog/config.rb +++ b/lib/gitlab/changelog/config.rb @@ -34,17 +34,17 @@ module Gitlab '(?:-(?P<pre>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))' \ '?(?:\+(?P<meta>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$' - attr_accessor :date_format, :categories, :template, :tag_regex + attr_accessor :date_format, :categories, :template, :tag_regex, :always_credit_user_ids - def self.from_git(project) + def self.from_git(project, user = nil) if (yaml = project.repository.changelog_config) - from_hash(project, YAML.safe_load(yaml)) + from_hash(project, YAML.safe_load(yaml), user) else new(project) end end - def self.from_hash(project, hash) + def self.from_hash(project, hash, user = nil) config = new(project) if (date = hash['date_format']) @@ -72,6 +72,14 @@ module Gitlab config.tag_regex = regex end + config.always_credit_user_ids = Set.new + if (group_paths = Array(hash['include_groups'])) + group_paths.each do |group_path| + group = Group.find_by_full_path(group_path) + config.always_credit_user_ids.merge(group&.users_ids_of_direct_members&.compact) if user&.can?(:read_group, group) + end + end + config end @@ -92,6 +100,10 @@ module Gitlab @project.team.contributor?(user&.id) end + def always_credit_author?(user) + always_credit_user_ids&.include?(user&.id) || false + end + def category(name) @categories[name] || name end diff --git a/lib/gitlab/changelog/release.rb b/lib/gitlab/changelog/release.rb index c0b6a5c5679..a0d598c7464 100644 --- a/lib/gitlab/changelog/release.rb +++ b/lib/gitlab/changelog/release.rb @@ -42,6 +42,7 @@ module Gitlab 'reference' => author.to_reference(full: true), 'contributor' => @config.contributor?(author) } + entry['author']['credit'] = entry['author']['contributor'] || @config.always_credit_author?(author) end if merge_request diff --git a/lib/gitlab/changelog/template.tpl b/lib/gitlab/changelog/template.tpl index 584939dff51..68c1c624394 100644 --- a/lib/gitlab/changelog/template.tpl +++ b/lib/gitlab/changelog/template.tpl @@ -4,7 +4,7 @@ {% each entries %} - [{{ title }}]({{ commit.reference }})\ -{% if author.contributor %} by {{ author.reference }}{% end %}\ +{% if author.credit %} by {{ author.reference }}{% end %}\ {% if merge_request %} ([merge request]({{ merge_request.reference }})){% end %} {% end %} diff --git a/package.json b/package.json index 45bd4f55990..572ac82dea3 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "@babel/preset-env": "^7.10.1", "@gitlab/at.js": "1.5.7", "@gitlab/favicon-overlay": "2.0.0", - "@gitlab/svgs": "1.211.0", + "@gitlab/svgs": "1.212.0", "@gitlab/tributejs": "1.0.0", "@gitlab/ui": "32.10.0", "@gitlab/visual-review-tools": "1.6.1", diff --git a/spec/controllers/user_callouts_controller_spec.rb b/spec/controllers/user_callouts_controller_spec.rb index 279f825e40f..3bb8d78a6b0 100644 --- a/spec/controllers/user_callouts_controller_spec.rb +++ b/spec/controllers/user_callouts_controller_spec.rb @@ -3,14 +3,16 @@ require 'spec_helper' RSpec.describe UserCalloutsController do - let(:user) { create(:user) } + let_it_be(:user) { create(:user) } before do sign_in(user) end describe "POST #create" do - subject { post :create, params: { feature_name: feature_name }, format: :json } + let(:params) { { feature_name: feature_name } } + + subject { post :create, params: params, format: :json } context 'with valid feature name' do let(:feature_name) { UserCallout.feature_names.each_key.first } @@ -30,9 +32,8 @@ RSpec.describe UserCalloutsController do context 'when callout entry already exists' do let!(:callout) { create(:user_callout, feature_name: UserCallout.feature_names.each_key.first, user: user) } - it 'returns success' do - subject - + it 'returns success', :aggregate_failures do + expect { subject }.not_to change { UserCallout.count } expect(response).to have_gitlab_http_status(:ok) end end diff --git a/spec/factories/users/group_user_callouts.rb b/spec/factories/users/group_user_callouts.rb new file mode 100644 index 00000000000..de8a6d3ee77 --- /dev/null +++ b/spec/factories/users/group_user_callouts.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :group_callout, class: 'Users::GroupCallout' do + feature_name { :invite_members_banner } + + user + group + end +end diff --git a/spec/features/groups/show_spec.rb b/spec/features/groups/show_spec.rb index 79226facad4..eb62b6fa8ee 100644 --- a/spec/features/groups/show_spec.rb +++ b/spec/features/groups/show_spec.rb @@ -3,25 +3,74 @@ require 'spec_helper' RSpec.describe 'Group show page' do - let(:group) { create(:group) } + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let(:path) { group_path(group) } context 'when signed in' do - let(:user) do - create(:group_member, :developer, user: create(:user), group: group ).user - end + context 'with non-admin group concerns' do + before do + group.add_developer(user) + sign_in(user) + visit path + end - before do - sign_in(user) - visit path + it_behaves_like "an autodiscoverable RSS feed with current_user's feed token" + + context 'when group does not exist' do + let(:path) { group_path('not-exist') } + + it { expect(status_code).to eq(404) } + end end - it_behaves_like "an autodiscoverable RSS feed with current_user's feed token" + context 'when user is an owner' do + before do + group.add_owner(user) + sign_in(user) + end + + it 'shows the invite banner and persists dismissal', :js do + visit path + + expect(page).to have_content('Collaborate with your team') - context 'when group does not exist' do - let(:path) { group_path('not-exist') } + page.within(find('[data-testid="invite-members-banner"]')) do + find('[data-testid="close-icon"]').click + end + + expect(page).not_to have_content('Collaborate with your team') + + visit path + + expect(page).not_to have_content('Collaborate with your team') + end + + context 'when group has a project with emoji in description', :js do + let!(:project) { create(:project, description: ':smile:', namespace: group) } + + it 'shows the project info', :aggregate_failures do + visit path + + expect(page).to have_content(project.title) + expect(page).to have_emoji('smile') + end + end - it { expect(status_code).to eq(404) } + context 'when group has projects' do + it 'allows users to sorts projects by most stars', :js do + project1 = create(:project, namespace: group, star_count: 2) + project2 = create(:project, namespace: group, star_count: 3) + project3 = create(:project, namespace: group, star_count: 0) + + visit group_path(group, sort: :stars_desc) + + expect(find('.group-row:nth-child(1) .namespace-title > a')).to have_content(project2.title) + expect(find('.group-row:nth-child(2) .namespace-title > a')).to have_content(project1.title) + expect(find('.group-row:nth-child(3) .namespace-title > a')).to have_content(project3.title) + end + end end end @@ -37,7 +86,7 @@ RSpec.describe 'Group show page' do context 'when group has a public project', :js do let!(:project) { create(:project, :public, namespace: group) } - it 'renders public project' do + it 'renders public project', :aggregate_failures do visit path expect(page).to have_link group.name @@ -48,7 +97,7 @@ RSpec.describe 'Group show page' do context 'when group has a private project', :js do let!(:project) { create(:project, :private, namespace: group) } - it 'does not render private project' do + it 'does not render private project', :aggregate_failures do visit path expect(page).to have_link group.name @@ -58,28 +107,19 @@ RSpec.describe 'Group show page' do end context 'subgroup support' do - let(:restricted_group) do + let_it_be(:restricted_group) do create(:group, subgroup_creation_level: ::Gitlab::Access::OWNER_SUBGROUP_ACCESS) end - let(:relaxed_group) do - create(:group, subgroup_creation_level: ::Gitlab::Access::MAINTAINER_SUBGROUP_ACCESS) - end - - let(:owner) { create(:user) } - let(:maintainer) { create(:user) } - context 'for owners' do - let(:path) { group_path(restricted_group) } - before do - restricted_group.add_owner(owner) - sign_in(owner) + restricted_group.add_owner(user) + sign_in(user) end context 'when subgroups are supported' do it 'allows creating subgroups' do - visit path + visit group_path(restricted_group) expect(page).to have_link('New subgroup') end @@ -88,18 +128,21 @@ RSpec.describe 'Group show page' do context 'for maintainers' do before do - sign_in(maintainer) + sign_in(user) end context 'when subgroups are supported' do context 'when subgroup_creation_level is set to maintainers' do + let(:relaxed_group) do + create(:group, subgroup_creation_level: ::Gitlab::Access::MAINTAINER_SUBGROUP_ACCESS) + end + before do - relaxed_group.add_maintainer(maintainer) + relaxed_group.add_maintainer(user) end it 'allows creating subgroups' do - path = group_path(relaxed_group) - visit path + visit group_path(relaxed_group) expect(page).to have_link('New subgroup') end @@ -107,12 +150,11 @@ RSpec.describe 'Group show page' do context 'when subgroup_creation_level is set to owners' do before do - restricted_group.add_maintainer(maintainer) + restricted_group.add_maintainer(user) end it 'does not allow creating subgroups' do - path = group_path(restricted_group) - visit path + visit group_path(restricted_group) expect(page).not_to have_link('New subgroup') end @@ -121,50 +163,10 @@ RSpec.describe 'Group show page' do end end - context 'group has a project with emoji in description', :js do - let(:user) { create(:user) } - let!(:project) { create(:project, description: ':smile:', namespace: group) } - - before do - group.add_owner(user) - sign_in(user) - visit path - end - - it 'shows the project info' do - expect(page).to have_content(project.title) - expect(page).to have_emoji('smile') - end - end - - context 'where group has projects' do - let(:user) { create(:user) } - - before do - group.add_owner(user) - sign_in(user) - end - - it 'allows users to sorts projects by most stars', :js do - project1 = create(:project, namespace: group, star_count: 2) - project2 = create(:project, namespace: group, star_count: 3) - project3 = create(:project, namespace: group, star_count: 0) - - visit group_path(group, sort: :stars_desc) - - expect(find('.group-row:nth-child(1) .namespace-title > a')).to have_content(project2.title) - expect(find('.group-row:nth-child(2) .namespace-title > a')).to have_content(project1.title) - expect(find('.group-row:nth-child(3) .namespace-title > a')).to have_content(project3.title) - end - end - context 'notification button', :js do - let(:maintainer) { create(:user) } - let!(:project) { create(:project, namespace: group) } - before do - group.add_maintainer(maintainer) - sign_in(maintainer) + group.add_maintainer(user) + sign_in(user) end it 'is enabled by default' do @@ -174,7 +176,8 @@ RSpec.describe 'Group show page' do end it 'is disabled if emails are disabled' do - group.update_attribute(:emails_disabled, true) + group.update!(emails_disabled: true) + visit path expect(page).to have_selector('[data-testid="notification-dropdown"] .disabled') @@ -182,12 +185,10 @@ RSpec.describe 'Group show page' do end context 'page og:description' do - let(:group) { create(:group, description: '**Lorem** _ipsum_ dolor sit [amet](https://example.com)') } - let(:maintainer) { create(:user) } - before do - group.add_maintainer(maintainer) - sign_in(maintainer) + group.update!(description: '**Lorem** _ipsum_ dolor sit [amet](https://example.com)') + group.add_maintainer(user) + sign_in(user) visit path end @@ -237,7 +238,7 @@ RSpec.describe 'Group show page' do end end - it 'does not include structured markup in shared projects tab', :js do + it 'does not include structured markup in shared projects tab', :aggregate_failures, :js do other_project = create(:project, :public) other_project.project_group_links.create!(group: group) @@ -248,7 +249,7 @@ RSpec.describe 'Group show page' do expect(page).not_to have_selector('[itemprop="owns"][itemtype="https://schema.org/SoftwareSourceCode"]') end - it 'does not include structured markup in archived projects tab', :js do + it 'does not include structured markup in archived projects tab', :aggregate_failures, :js do project.update!(archived: true) visit group_archived_path(group) diff --git a/spec/frontend/groups/components/invite_members_banner_spec.js b/spec/frontend/groups/components/invite_members_banner_spec.js index 0da2f84f2a1..c81edad499c 100644 --- a/spec/frontend/groups/components/invite_members_banner_spec.js +++ b/spec/frontend/groups/components/invite_members_banner_spec.js @@ -1,29 +1,29 @@ -import { GlBanner, GlButton } from '@gitlab/ui'; +import { GlBanner } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import InviteMembersBanner from '~/groups/components/invite_members_banner.vue'; import eventHub from '~/invite_members/event_hub'; -import { setCookie, parseBoolean } from '~/lib/utils/common_utils'; +import axios from '~/lib/utils/axios_utils'; jest.mock('~/lib/utils/common_utils'); -const isDismissedKey = 'invite_99_1'; const title = 'Collaborate with your team'; const body = "We noticed that you haven't invited anyone to this group. Invite your colleagues so you can discuss issues, collaborate on merge requests, and share your knowledge"; -const svgPath = '/illustrations/background'; -const inviteMembersPath = 'groups/members'; const buttonText = 'Invite your colleagues'; -const trackLabel = 'invite_members_banner'; +const provide = { + svgPath: '/illustrations/background', + inviteMembersPath: 'groups/members', + trackLabel: 'invite_members_banner', + calloutsPath: 'call/out/path', + calloutsFeatureId: 'some-feature-id', + groupId: '1', +}; const createComponent = (stubs = {}) => { return shallowMount(InviteMembersBanner, { - provide: { - svgPath, - inviteMembersPath, - isDismissedKey, - trackLabel, - }, + provide, stubs, }); }; @@ -31,8 +31,10 @@ const createComponent = (stubs = {}) => { describe('InviteMembersBanner', () => { let wrapper; let trackingSpy; + let mockAxios; beforeEach(() => { + mockAxios = new MockAdapter(axios); document.body.dataset.page = 'any:page'; trackingSpy = mockTracking('_category_', undefined, jest.spyOn); }); @@ -40,22 +42,28 @@ describe('InviteMembersBanner', () => { afterEach(() => { wrapper.destroy(); wrapper = null; + mockAxios.restore(); unmockTracking(); }); describe('tracking', () => { + const mockTrackingOnWrapper = () => { + unmockTracking(); + trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); + }; + beforeEach(() => { wrapper = createComponent({ GlBanner }); }); const trackCategory = undefined; - const displayEvent = 'invite_members_banner_displayed'; const buttonClickEvent = 'invite_members_banner_button_clicked'; - const dismissEvent = 'invite_members_banner_dismissed'; it('sends the displayEvent when the banner is displayed', () => { + const displayEvent = 'invite_members_banner_displayed'; + expect(trackingSpy).toHaveBeenCalledWith(trackCategory, displayEvent, { - label: trackLabel, + label: provide.trackLabel, }); }); @@ -74,16 +82,20 @@ describe('InviteMembersBanner', () => { it('sends the buttonClickEvent with correct trackCategory and trackLabel', () => { expect(trackingSpy).toHaveBeenCalledWith(trackCategory, buttonClickEvent, { - label: trackLabel, + label: provide.trackLabel, }); }); }); it('sends the dismissEvent when the banner is dismissed', () => { + mockTrackingOnWrapper(); + mockAxios.onPost(provide.calloutsPath).replyOnce(200); + const dismissEvent = 'invite_members_banner_dismissed'; + wrapper.find(GlBanner).vm.$emit('close'); expect(trackingSpy).toHaveBeenCalledWith(trackCategory, dismissEvent, { - label: trackLabel, + label: provide.trackLabel, }); }); }); @@ -98,7 +110,7 @@ describe('InviteMembersBanner', () => { }); it('uses the svgPath for the banner svgpath', () => { - expect(findBanner().attributes('svgpath')).toBe(svgPath); + expect(findBanner().attributes('svgpath')).toBe(provide.svgPath); }); it('uses the title from options for title', () => { @@ -115,35 +127,20 @@ describe('InviteMembersBanner', () => { }); describe('dismissing', () => { - const findButton = () => wrapper.findAll(GlButton).at(1); - beforeEach(() => { wrapper = createComponent({ GlBanner }); - - findButton().vm.$emit('click'); }); - it('sets iDismissed to true', () => { - expect(wrapper.vm.isDismissed).toBe(true); + it('should render the banner when not dismissed', () => { + expect(wrapper.find(GlBanner).exists()).toBe(true); }); - it('sets the cookie with the isDismissedKey', () => { - expect(setCookie).toHaveBeenCalledWith(isDismissedKey, true); - }); - }); - - describe('when a dismiss cookie exists', () => { - beforeEach(() => { - parseBoolean.mockReturnValue(true); - - wrapper = createComponent({ GlBanner }); - }); - - it('sets isDismissed to true', () => { - expect(wrapper.vm.isDismissed).toBe(true); - }); + it('should close the banner when dismiss is clicked', async () => { + mockAxios.onPost(provide.calloutsPath).replyOnce(200); + expect(wrapper.find(GlBanner).exists()).toBe(true); + wrapper.find(GlBanner).vm.$emit('close'); - it('does not render the banner', () => { + await wrapper.vm.$nextTick(); expect(wrapper.find(GlBanner).exists()).toBe(false); }); }); diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb index 6b53973fe16..825d5236b5d 100644 --- a/spec/helpers/groups_helper_spec.rb +++ b/spec/helpers/groups_helper_spec.rb @@ -375,67 +375,6 @@ RSpec.describe GroupsHelper do end end - describe '#show_invite_banner?' do - let_it_be(:current_user) { create(:user) } - let_it_be_with_refind(:group) { create(:group) } - let_it_be(:subgroup) { create(:group, parent: group) } - let_it_be(:users) { [current_user, create(:user)] } - - before do - allow(helper).to receive(:current_user) { current_user } - allow(helper).to receive(:can?).with(current_user, :admin_group, group).and_return(can_admin_group) - allow(helper).to receive(:can?).with(current_user, :admin_group, subgroup).and_return(can_admin_group) - users.take(group_members_count).each { |user| group.add_guest(user) } - end - - using RSpec::Parameterized::TableSyntax - - where(:can_admin_group, :group_members_count, :expected_result) do - true | 1 | true - false | 1 | false - true | 2 | false - false | 2 | false - end - - with_them do - context 'for a parent group' do - subject { helper.show_invite_banner?(group) } - - context 'when the group was just created' do - before do - flash[:notice] = "Group #{group.name} was successfully created" - end - - it { is_expected.to be_falsey } - end - - context 'when no flash message' do - it 'returns the expected result' do - expect(subject).to eq(expected_result) - end - end - end - - context 'for a subgroup' do - subject { helper.show_invite_banner?(subgroup) } - - context 'when the subgroup was just created' do - before do - flash[:notice] = "Group #{subgroup.name} was successfully created" - end - - it { is_expected.to be_falsey } - end - - context 'when no flash message' do - it 'returns the expected result' do - expect(subject).to eq(expected_result) - end - end - end - end - end - describe '#render_setting_to_allow_project_access_token_creation?' do let_it_be(:current_user) { create(:user) } let_it_be(:parent) { create(:group) } diff --git a/spec/helpers/user_callouts_helper_spec.rb b/spec/helpers/user_callouts_helper_spec.rb index 5ef1e9d4daf..794ff5ee945 100644 --- a/spec/helpers/user_callouts_helper_spec.rb +++ b/spec/helpers/user_callouts_helper_spec.rb @@ -3,7 +3,7 @@ require "spec_helper" RSpec.describe UserCalloutsHelper do - let_it_be(:user) { create(:user) } + let_it_be(:user, refind: true) { create(:user) } before do allow(helper).to receive(:current_user).and_return(user) @@ -202,4 +202,95 @@ RSpec.describe UserCalloutsHelper do it { is_expected.to be false } end end + + describe '.show_invite_banner?' do + let_it_be(:group) { create(:group) } + + subject { helper.show_invite_banner?(group) } + + context 'when user has the admin ability for the group' do + before do + group.add_owner(user) + end + + context 'when the invite_members_banner has not been dismissed' do + it { is_expected.to eq(true) } + + context 'when a user has dismissed this banner via cookies already' do + before do + helper.request.cookies["invite_#{group.id}_#{user.id}"] = 'true' + end + + it { is_expected.to eq(false) } + + it 'creates the callout from cookie', :aggregate_failures do + expect { subject }.to change { Users::GroupCallout.count }.by(1) + expect(Users::GroupCallout.last).to have_attributes(group_id: group.id, + feature_name: described_class::INVITE_MEMBERS_BANNER) + end + end + + context 'when the group was just created' do + before do + flash[:notice] = "Group #{group.name} was successfully created" + end + + it { is_expected.to eq(false) } + end + + context 'with concerning multiple members' do + let_it_be(:user_2) { create(:user) } + + context 'on current group' do + before do + group.add_guest(user_2) + end + + it { is_expected.to eq(false) } + end + + context 'on current group that is a subgroup' do + let_it_be(:subgroup) { create(:group, parent: group) } + + subject { helper.show_invite_banner?(subgroup) } + + context 'with only one user on parent and this group' do + it { is_expected.to eq(true) } + end + + context 'when another user is on this group' do + before do + subgroup.add_guest(user_2) + end + + it { is_expected.to eq(false) } + end + + context 'when another user is on the parent group' do + before do + group.add_guest(user_2) + end + + it { is_expected.to eq(false) } + end + end + end + end + + context 'when the invite_members_banner has been dismissed' do + before do + create(:group_callout, + user: user, + group: group, + feature_name: described_class::INVITE_MEMBERS_BANNER) + end + + it { is_expected.to eq(false) } + end + end + + context 'when user does not have admin ability for the group' do + it { is_expected.to eq(false) } + end + end end diff --git a/spec/lib/gitlab/changelog/config_spec.rb b/spec/lib/gitlab/changelog/config_spec.rb index ff5a084bb86..c410ba4d116 100644 --- a/spec/lib/gitlab/changelog/config_spec.rb +++ b/spec/lib/gitlab/changelog/config_spec.rb @@ -15,7 +15,7 @@ RSpec.describe Gitlab::Changelog::Config do expect(described_class) .to receive(:from_hash) - .with(project, 'date_format' => '%Y') + .with(project, { 'date_format' => '%Y' }, nil) described_class.from_git(project) end @@ -35,12 +35,25 @@ RSpec.describe Gitlab::Changelog::Config do describe '.from_hash' do it 'sets the configuration according to a Hash' do + user1 = create(:user) + user2 = create(:user) + user3 = create(:user) + group = create(:group, path: 'group') + group2 = create(:group, path: 'group-path') + group.add_developer(user1) + group.add_developer(user2) + group2.add_developer(user3) + config = described_class.from_hash( project, - 'date_format' => 'foo', - 'template' => 'bar', - 'categories' => { 'foo' => 'bar' }, - 'tag_regex' => 'foo' + { + 'date_format' => 'foo', + 'template' => 'bar', + 'categories' => { 'foo' => 'bar' }, + 'tag_regex' => 'foo', + 'include_groups' => %w[group group-path non-existent-group] + }, + user1 ) expect(config.date_format).to eq('foo') @@ -49,6 +62,7 @@ RSpec.describe Gitlab::Changelog::Config do expect(config.categories).to eq({ 'foo' => 'bar' }) expect(config.tag_regex).to eq('foo') + expect(config.always_credit_user_ids).to match_array([user1.id, user2.id, user3.id]) end it 'raises Error when the categories are not a Hash' do @@ -122,4 +136,55 @@ RSpec.describe Gitlab::Changelog::Config do expect(config.format_date(time)).to eq('2021-01-05') end end + + describe '#always_credit_author?' do + let_it_be(:group_member) { create(:user) } + let_it_be(:non_group_member) { create(:user) } + let_it_be(:group) { create(:group, :private, path: 'group') } + + before do + group.add_developer(group_member) + end + + context 'when include_groups is defined' do + context 'when user generating changelog has access to group' do + it 'returns whether author should always be credited' do + config = described_class.from_hash( + project, + { 'include_groups' => ['group'] }, + group_member + ) + + expect(config.always_credit_author?(group_member)).to eq(true) + expect(config.always_credit_author?(non_group_member)).to eq(false) + end + end + + context 'when user generating changelog has no access to group' do + it 'always returns false' do + config = described_class.from_hash( + project, + { 'include_groups' => ['group'] }, + non_group_member + ) + + expect(config.always_credit_author?(group_member)).to eq(false) + expect(config.always_credit_author?(non_group_member)).to eq(false) + end + end + end + + context 'when include_groups is not defined' do + it 'always returns false' do + config = described_class.from_hash( + project, + {}, + group_member + ) + + expect(config.always_credit_author?(group_member)).to eq(false) + expect(config.always_credit_author?(non_group_member)).to eq(false) + end + end + end end diff --git a/spec/lib/gitlab/changelog/release_spec.rb b/spec/lib/gitlab/changelog/release_spec.rb index f95244d6750..d8434821640 100644 --- a/spec/lib/gitlab/changelog/release_spec.rb +++ b/spec/lib/gitlab/changelog/release_spec.rb @@ -94,6 +94,30 @@ RSpec.describe Gitlab::Changelog::Release do end end + context 'when the author should always be credited' do + it 'includes the author' do + allow(config).to receive(:contributor?).with(author).and_return(false) + allow(config).to receive(:always_credit_author?).with(author).and_return(true) + + release.add_entry( + title: 'Entry title', + commit: commit, + category: 'fixed', + author: author + ) + + expect(release.to_markdown).to eq(<<~OUT) + ## 1.0.0 (2021-01-05) + + ### fixed (1 change) + + - [Entry title](#{commit.to_reference(full: true)}) \ + by #{author.to_reference(full: true)} + + OUT + end + end + context 'when a category has no entries' do it "isn't included in the output" do config.categories['kittens'] = 'Kittens' diff --git a/spec/models/concerns/calloutable_spec.rb b/spec/models/concerns/calloutable_spec.rb new file mode 100644 index 00000000000..d847413de88 --- /dev/null +++ b/spec/models/concerns/calloutable_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Calloutable do + subject { build(:user_callout) } + + describe "Associations" do + it { is_expected.to belong_to(:user) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:user) } + end + + describe '#dismissed_after?' do + let(:some_feature_name) { UserCallout.feature_names.keys.second } + let(:callout_dismissed_month_ago) { create(:user_callout, feature_name: some_feature_name, dismissed_at: 1.month.ago )} + let(:callout_dismissed_day_ago) { create(:user_callout, feature_name: some_feature_name, dismissed_at: 1.day.ago )} + + it 'returns whether a callout dismissed after specified date' do + expect(callout_dismissed_month_ago.dismissed_after?(15.days.ago)).to eq(false) + expect(callout_dismissed_day_ago.dismissed_after?(15.days.ago)).to eq(true) + end + end +end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 38f3eadbb22..d177f5380fb 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -35,6 +35,7 @@ RSpec.describe Group do it { is_expected.to have_many(:dependency_proxy_manifests) } it { is_expected.to have_many(:debian_distributions).class_name('Packages::Debian::GroupDistribution').dependent(:destroy) } it { is_expected.to have_many(:daily_build_group_report_results).class_name('Ci::DailyBuildGroupReportResult') } + it { is_expected.to have_many(:group_callouts).class_name('Users::GroupCallout').with_foreign_key(:group_id) } describe '#members & #requesters' do let(:requester) { create(:user) } diff --git a/spec/models/user_callout_spec.rb b/spec/models/user_callout_spec.rb index eb66f074293..5b36c8450ea 100644 --- a/spec/models/user_callout_spec.rb +++ b/spec/models/user_callout_spec.rb @@ -3,29 +3,12 @@ require 'spec_helper' RSpec.describe UserCallout do - let!(:callout) { create(:user_callout) } + let_it_be(:callout) { create(:user_callout) } it_behaves_like 'having unique enum values' - describe 'relationships' do - it { is_expected.to belong_to(:user) } - end - describe 'validations' do - it { is_expected.to validate_presence_of(:user) } - it { is_expected.to validate_presence_of(:feature_name) } it { is_expected.to validate_uniqueness_of(:feature_name).scoped_to(:user_id).ignoring_case_sensitivity } end - - describe '#dismissed_after?' do - let(:some_feature_name) { described_class.feature_names.keys.second } - let(:callout_dismissed_month_ago) { create(:user_callout, feature_name: some_feature_name, dismissed_at: 1.month.ago )} - let(:callout_dismissed_day_ago) { create(:user_callout, feature_name: some_feature_name, dismissed_at: 1.day.ago )} - - it 'returns whether a callout dismissed after specified date' do - expect(callout_dismissed_month_ago.dismissed_after?(15.days.ago)).to eq(false) - expect(callout_dismissed_day_ago.dismissed_after?(15.days.ago)).to eq(true) - end - end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index ba20343c30a..306a5534737 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -120,6 +120,8 @@ RSpec.describe User do it { is_expected.to have_many(:created_custom_emoji).inverse_of(:creator) } it { is_expected.to have_many(:in_product_marketing_emails) } it { is_expected.to have_many(:timelogs) } + it { is_expected.to have_many(:callouts).class_name('UserCallout') } + it { is_expected.to have_many(:group_callouts).class_name('Users::GroupCallout') } describe "#user_detail" do it 'does not persist `user_detail` by default' do @@ -5542,22 +5544,17 @@ RSpec.describe User do end describe '#dismissed_callout?' do - subject(:user) { create(:user) } - - let(:feature_name) { UserCallout.feature_names.each_key.first } + let_it_be(:user, refind: true) { create(:user) } + let_it_be(:feature_name) { UserCallout.feature_names.each_key.first } context 'when no callout dismissal record exists' do it 'returns false when no ignore_dismissal_earlier_than provided' do expect(user.dismissed_callout?(feature_name: feature_name)).to eq false end - - it 'returns false when ignore_dismissal_earlier_than provided' do - expect(user.dismissed_callout?(feature_name: feature_name, ignore_dismissal_earlier_than: 3.months.ago)).to eq false - end end context 'when dismissed callout exists' do - before do + before_all do create(:user_callout, user: user, feature_name: feature_name, dismissed_at: 4.months.ago) end @@ -5575,6 +5572,123 @@ RSpec.describe User do end end + describe '#find_or_initialize_callout' do + let_it_be(:user, refind: true) { create(:user) } + let_it_be(:feature_name) { UserCallout.feature_names.each_key.first } + + subject(:find_or_initialize_callout) { user.find_or_initialize_callout(feature_name) } + + context 'when callout exists' do + let!(:callout) { create(:user_callout, user: user, feature_name: feature_name) } + + it 'returns existing callout' do + expect(find_or_initialize_callout).to eq(callout) + end + end + + context 'when callout does not exist' do + context 'when feature name is valid' do + it 'initializes a new callout' do + expect(find_or_initialize_callout).to be_a_new(UserCallout) + end + + it 'is valid' do + expect(find_or_initialize_callout).to be_valid + end + end + + context 'when feature name is not valid' do + let(:feature_name) { 'notvalid' } + + it 'initializes a new callout' do + expect(find_or_initialize_callout).to be_a_new(UserCallout) + end + + it 'is not valid' do + expect(find_or_initialize_callout).not_to be_valid + end + end + end + end + + describe '#dismissed_callout_for_group?' do + let_it_be(:user, refind: true) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:feature_name) { Users::GroupCallout.feature_names.each_key.first } + + context 'when no callout dismissal record exists' do + it 'returns false when no ignore_dismissal_earlier_than provided' do + expect(user.dismissed_callout_for_group?(feature_name: feature_name, group: group)).to eq false + end + end + + context 'when dismissed callout exists' do + before_all do + create(:group_callout, + user: user, + group_id: group.id, + feature_name: feature_name, + dismissed_at: 4.months.ago) + end + + it 'returns true when no ignore_dismissal_earlier_than provided' do + expect(user.dismissed_callout_for_group?(feature_name: feature_name, group: group)).to eq true + end + + it 'returns true when ignore_dismissal_earlier_than is earlier than dismissed_at' do + expect(user.dismissed_callout_for_group?(feature_name: feature_name, group: group, ignore_dismissal_earlier_than: 6.months.ago)).to eq true + end + + it 'returns false when ignore_dismissal_earlier_than is later than dismissed_at' do + expect(user.dismissed_callout_for_group?(feature_name: feature_name, group: group, ignore_dismissal_earlier_than: 3.months.ago)).to eq false + end + end + end + + describe '#find_or_initialize_group_callout' do + let_it_be(:user, refind: true) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:feature_name) { Users::GroupCallout.feature_names.each_key.first } + + subject(:callout_with_source) do + user.find_or_initialize_group_callout(feature_name, group.id) + end + + context 'when callout exists' do + let!(:callout) do + create(:group_callout, user: user, feature_name: feature_name, group_id: group.id) + end + + it 'returns existing callout' do + expect(callout_with_source).to eq(callout) + end + end + + context 'when callout does not exist' do + context 'when feature name is valid' do + it 'initializes a new callout' do + expect(callout_with_source).to be_a_new(Users::GroupCallout) + end + + it 'is valid' do + expect(callout_with_source).to be_valid + end + end + + context 'when feature name is not valid' do + let(:feature_name) { 'notvalid' } + + it 'initializes a new callout' do + expect(callout_with_source).to be_a_new(Users::GroupCallout) + end + + it 'is not valid' do + expect(callout_with_source).not_to be_valid + end + end + end + end + describe '#hook_attrs' do it 'includes id, name, username, avatar_url, and email' do user = create(:user) @@ -5937,45 +6051,6 @@ RSpec.describe User do end end - describe '#find_or_initialize_callout' do - subject(:find_or_initialize_callout) { user.find_or_initialize_callout(feature_name) } - - let(:user) { create(:user) } - let(:feature_name) { UserCallout.feature_names.each_key.first } - - context 'when callout exists' do - let!(:callout) { create(:user_callout, user: user, feature_name: feature_name) } - - it 'returns existing callout' do - expect(find_or_initialize_callout).to eq(callout) - end - end - - context 'when callout does not exist' do - context 'when feature name is valid' do - it 'initializes a new callout' do - expect(find_or_initialize_callout).to be_a_new(UserCallout) - end - - it 'is valid' do - expect(find_or_initialize_callout).to be_valid - end - end - - context 'when feature name is not valid' do - let(:feature_name) { 'notvalid' } - - it 'initializes a new callout' do - expect(find_or_initialize_callout).to be_a_new(UserCallout) - end - - it 'is not valid' do - expect(find_or_initialize_callout).not_to be_valid - end - end - end - end - describe '#default_dashboard?' do it 'is the default dashboard' do user = build(:user) diff --git a/spec/models/users/group_callout_spec.rb b/spec/models/users/group_callout_spec.rb new file mode 100644 index 00000000000..461b5fd7715 --- /dev/null +++ b/spec/models/users/group_callout_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Users::GroupCallout do + let_it_be(:user) { create_default(:user) } + let_it_be(:group) { create_default(:group) } + let_it_be(:callout) { create(:group_callout) } + + it_behaves_like 'having unique enum values' + + describe 'relationships' do + it { is_expected.to belong_to(:group) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:group) } + it { is_expected.to validate_presence_of(:feature_name) } + it { is_expected.to validate_uniqueness_of(:feature_name).scoped_to(:user_id, :group_id).ignoring_case_sensitivity } + end + + describe '#source_feature_name' do + it 'provides string based off source and feature' do + expect(callout.source_feature_name).to eq "#{callout.feature_name}_#{callout.group_id}" + end + end +end diff --git a/spec/requests/users/group_callouts_spec.rb b/spec/requests/users/group_callouts_spec.rb new file mode 100644 index 00000000000..a8680c3add4 --- /dev/null +++ b/spec/requests/users/group_callouts_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Group callouts' do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + + before do + sign_in(user) + end + + describe 'POST /-/users/group_callouts' do + let(:params) { { feature_name: feature_name, group_id: group.id } } + + subject { post group_callouts_path, params: params, headers: { 'ACCEPT' => 'application/json' } } + + context 'with valid feature name and group' do + let(:feature_name) { Users::GroupCallout.feature_names.each_key.first } + + context 'when callout entry does not exist' do + it 'creates a callout entry with dismissed state' do + expect { subject }.to change { Users::GroupCallout.count }.by(1) + end + + it 'returns success' do + subject + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'when callout entry already exists' do + let!(:callout) do + create(:group_callout, + feature_name: Users::GroupCallout.feature_names.each_key.first, + user: user, + group: group) + end + + it 'returns success', :aggregate_failures do + expect { subject }.not_to change { Users::GroupCallout.count } + expect(response).to have_gitlab_http_status(:ok) + end + end + end + + context 'with invalid feature name' do + let(:feature_name) { 'bogus_feature_name' } + + it 'returns bad request' do + subject + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + end +end diff --git a/spec/services/users/dismiss_group_callout_service_spec.rb b/spec/services/users/dismiss_group_callout_service_spec.rb new file mode 100644 index 00000000000..d74602a7606 --- /dev/null +++ b/spec/services/users/dismiss_group_callout_service_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Users::DismissGroupCalloutService do + describe '#execute' do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + + let(:params) { { feature_name: feature_name, group_id: group.id } } + let(:feature_name) { Users::GroupCallout.feature_names.each_key.first } + + subject(:execute) do + described_class.new( + container: nil, current_user: user, params: params + ).execute + end + + it_behaves_like 'dismissing user callout', Users::GroupCallout + + it 'sets the group_id' do + expect(execute.group_id).to eq(group.id) + end + end +end diff --git a/spec/services/users/dismiss_user_callout_service_spec.rb b/spec/services/users/dismiss_user_callout_service_spec.rb index 22f84a939f7..6bf9961eb74 100644 --- a/spec/services/users/dismiss_user_callout_service_spec.rb +++ b/spec/services/users/dismiss_user_callout_service_spec.rb @@ -3,25 +3,18 @@ require 'spec_helper' RSpec.describe Users::DismissUserCalloutService do - let(:user) { create(:user) } - - let(:service) do - described_class.new( - container: nil, current_user: user, params: { feature_name: UserCallout.feature_names.each_key.first } - ) - end - describe '#execute' do - subject(:execute) { service.execute } + let_it_be(:user) { create(:user) } - it 'returns a user callout' do - expect(execute).to be_an_instance_of(UserCallout) - end + let(:params) { { feature_name: feature_name } } + let(:feature_name) { UserCallout.feature_names.each_key.first } - it 'sets the dismisse_at attribute to current time' do - freeze_time do - expect(execute).to have_attributes(dismissed_at: Time.current) - end + subject(:execute) do + described_class.new( + container: nil, current_user: user, params: params + ).execute end + + it_behaves_like 'dismissing user callout', UserCallout end end diff --git a/spec/support/shared_examples/services/users/dismiss_user_callout_service_shared_examples.rb b/spec/support/shared_examples/services/users/dismiss_user_callout_service_shared_examples.rb new file mode 100644 index 00000000000..09820593cdb --- /dev/null +++ b/spec/support/shared_examples/services/users/dismiss_user_callout_service_shared_examples.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.shared_examples_for 'dismissing user callout' do |model| + it 'creates a new user callout' do + expect { execute }.to change { model.count }.by(1) + end + + it 'returns a user callout' do + expect(execute).to be_an_instance_of(model) + end + + it 'sets the dismissed_at attribute to current time' do + freeze_time do + expect(execute).to have_attributes(dismissed_at: Time.current) + end + end + + it 'updates an existing callout dismissed_at time' do + freeze_time do + old_time = 1.day.ago + new_time = Time.current + attributes = params.merge(dismissed_at: old_time, user: user) + existing_callout = create("#{model.name.split('::').last.underscore}".to_sym, attributes) + + expect { execute }.to change { existing_callout.reload.dismissed_at }.from(old_time).to(new_time) + end + end + + it 'does not update an invalid record with dismissed_at time', :aggregate_failures do + callout = described_class.new( + container: nil, current_user: user, params: { feature_name: nil } + ).execute + + expect(callout.dismissed_at).to be_nil + expect(callout).to be_invalid + end +end diff --git a/workhorse/internal/upstream/routes.go b/workhorse/internal/upstream/routes.go index a4b453f047d..8c85c5144e5 100644 --- a/workhorse/internal/upstream/routes.go +++ b/workhorse/internal/upstream/routes.go @@ -359,7 +359,7 @@ func configureRoutes(u *upstream) { u.route("", "^/-/metrics$", defaultUpstream), // Authentication routes - u.route("", "^/users/(sign_in|sign_out)$", defaultUpstream), + u.route("", "^/users/auth/geo/(sign_in|sign_out)$", defaultUpstream), u.route("", "^/oauth/geo/(auth|callback|logout)$", defaultUpstream), // Admin Area > Geo routes diff --git a/yarn.lock b/yarn.lock index a49d73e9f4b..a2aa52b9542 100644 --- a/yarn.lock +++ b/yarn.lock @@ -964,10 +964,10 @@ stylelint-declaration-strict-value "1.7.7" stylelint-scss "3.18.0" -"@gitlab/svgs@1.211.0": - version "1.211.0" - resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.211.0.tgz#0351fa4cc008c4830f366aede535df0a8e63dda6" - integrity sha512-fkHJfmKiy7lDwLFQ6z64sbGL+/hDDLzcMTj8O+VBC1xnlBVAIxe2eIs2DZLJcJwgLWncf4Uovp8+CeEfCY12sw== +"@gitlab/svgs@1.212.0": + version "1.212.0" + resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.212.0.tgz#21a5df04c52b10cc1b8521cd8ff7c7d6d13716db" + integrity sha512-dv0bYTHA3hwi3mNU3bGMq1cd4HVKKFNwCNPgkF91JSp4Xt8DDtJ0Yq4X49ASsq4zCJ3odgkq2aPjEa/Sr5nINQ== "@gitlab/tributejs@1.0.0": version "1.0.0" |