Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-09-15 03:09:30 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-09-15 03:09:30 +0300
commitb754c00a217814cdf3fdaaa51e695a44095c0197 (patch)
tree7844f33736ea727a1e7a48a5e5c4585ab9161f28
parentfb8839a8cd34244622d6361f1a1f7e49265813d1 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.gitlab/changelog_config.yml4
-rw-r--r--.rubocop_todo.yml1
-rw-r--r--app/assets/javascripts/groups/components/invite_members_banner.vue18
-rw-r--r--app/assets/javascripts/groups/init_invite_members_banner.js13
-rw-r--r--app/controllers/user_callouts_controller.rb10
-rw-r--r--app/controllers/users/group_callouts_controller.rb17
-rw-r--r--app/helpers/groups_helper.rb14
-rw-r--r--app/helpers/user_callouts_helper.rb45
-rw-r--r--app/models/concerns/calloutable.rb15
-rw-r--r--app/models/group.rb2
-rw-r--r--app/models/user.rb27
-rw-r--r--app/models/user_callout.rb7
-rw-r--r--app/models/users/group_callout.rb25
-rw-r--r--app/services/repositories/changelog_service.rb2
-rw-r--r--app/services/users/dismiss_group_callout_service.rb11
-rw-r--r--app/services/users/dismiss_user_callout_service.rb10
-rw-r--r--app/views/groups/show.html.haml6
-rw-r--r--config/routes/user.rb36
-rw-r--r--db/migrate/20210823172643_create_user_group_callout.rb19
-rw-r--r--db/migrate/20210907182337_add_group_id_fkey_for_user_group_callout.rb15
-rw-r--r--db/migrate/20210907182359_add_user_id_fkey_for_user_group_callout.rb15
-rw-r--r--db/schema_migrations/202108231726431
-rw-r--r--db/schema_migrations/202109071823371
-rw-r--r--db/schema_migrations/202109071823591
-rw-r--r--db/structure.sql32
-rw-r--r--doc/administration/gitaly/praefect.md25
-rw-r--r--doc/api/repositories.md15
-rw-r--r--doc/user/project/merge_requests/test_coverage_visualization.md3
-rw-r--r--lib/gitlab/changelog/config.rb20
-rw-r--r--lib/gitlab/changelog/release.rb1
-rw-r--r--lib/gitlab/changelog/template.tpl2
-rw-r--r--package.json2
-rw-r--r--spec/controllers/user_callouts_controller_spec.rb11
-rw-r--r--spec/factories/users/group_user_callouts.rb10
-rw-r--r--spec/features/groups/show_spec.rb167
-rw-r--r--spec/frontend/groups/components/invite_members_banner_spec.js77
-rw-r--r--spec/helpers/groups_helper_spec.rb61
-rw-r--r--spec/helpers/user_callouts_helper_spec.rb93
-rw-r--r--spec/lib/gitlab/changelog/config_spec.rb75
-rw-r--r--spec/lib/gitlab/changelog/release_spec.rb24
-rw-r--r--spec/models/concerns/calloutable_spec.rb26
-rw-r--r--spec/models/group_spec.rb1
-rw-r--r--spec/models/user_callout_spec.rb19
-rw-r--r--spec/models/user_spec.rb169
-rw-r--r--spec/models/users/group_callout_spec.rb27
-rw-r--r--spec/requests/users/group_callouts_spec.rb58
-rw-r--r--spec/services/users/dismiss_group_callout_service_spec.rb25
-rw-r--r--spec/services/users/dismiss_user_callout_service_spec.rb25
-rw-r--r--spec/support/shared_examples/services/users/dismiss_user_callout_service_shared_examples.rb37
-rw-r--r--workhorse/internal/upstream/routes.go2
-rw-r--r--yarn.lock8
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"