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>2020-12-03 03:09:53 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-12-03 03:09:53 +0300
commit1502c20d04c7ff8d719175c76b0a2507ab390172 (patch)
treedc01bfe0877bd93e7047db28dd972c7d597a527b
parentf96f2720d1b21b76eadedc54fdea67cb70e98d94 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue12
-rw-r--r--app/assets/javascripts/integrations/edit/store/actions.js21
-rw-r--r--app/assets/javascripts/integrations/edit/store/mutation_types.js3
-rw-r--r--app/assets/javascripts/integrations/edit/store/mutations.js6
-rw-r--r--app/assets/javascripts/pipelines/components/graph/utils.js34
-rw-r--r--app/assets/javascripts/pipelines/components/unwrapping_utils.js36
-rw-r--r--app/controllers/admin/users_controller.rb10
-rw-r--r--app/controllers/concerns/integrations_actions.rb6
-rw-r--r--app/controllers/invites_controller.rb11
-rw-r--r--app/graphql/types/merge_request_connection_type.rb15
-rw-r--r--app/graphql/types/merge_request_type.rb2
-rw-r--r--app/helpers/services_helper.rb16
-rw-r--r--app/mailers/emails/members.rb6
-rw-r--r--app/mailers/emails/profile.rb8
-rw-r--r--app/models/merge_request.rb7
-rw-r--r--app/models/merge_request/metrics.rb5
-rw-r--r--app/policies/global_policy.rb1
-rw-r--r--app/services/members/invitation_reminder_email_service.rb2
-rw-r--r--app/services/notification_service.rb4
-rw-r--r--app/services/users/reject_service.rb28
-rw-r--r--app/views/admin/users/_reject_pending_user.html.haml7
-rw-r--r--app/views/admin/users/_user.html.haml5
-rw-r--r--app/views/admin/users/_user_reject_effects.html.haml10
-rw-r--r--app/views/admin/users/show.html.haml88
-rw-r--r--app/views/notify/user_admin_rejection_email.html.haml5
-rw-r--r--app/views/notify/user_admin_rejection_email.text.erb6
-rw-r--r--app/views/shared/_service_settings.html.haml4
-rw-r--r--app/workers/member_invitation_reminder_emails_worker.rb2
-rw-r--r--changelogs/unreleased/227765_add_index_vulnerabilities_on_project_id_and_severity.yml6
-rw-r--r--changelogs/unreleased/262112_populate_remaining_dismissal_information.yml5
-rw-r--r--changelogs/unreleased/280596-user-admin-approval-reject-user.yml5
-rw-r--r--changelogs/unreleased/add-mean-time-to-merge-be.yml5
-rw-r--r--config/feature_flags/development/reset_integrations.yml8
-rw-r--r--config/routes/admin.rb2
-rw-r--r--config/routes/group.rb1
-rw-r--r--db/migrate/20201104142036_add_index_to_merge_request_metrics_target_project_id.rb18
-rw-r--r--db/migrate/20201201175656_add_index_vulnerabilities_on_project_id_and_state_and_severity.rb18
-rw-r--r--db/migrate/20201202142751_drop_index_vulnerabilities_on_project_id.rb18
-rw-r--r--db/post_migrate/20201124122817_populate_remaining_missing_dismissal_information_for_vulnerabilities.rb21
-rw-r--r--db/schema_migrations/202011041420361
-rw-r--r--db/schema_migrations/202011241228171
-rw-r--r--db/schema_migrations/202012011756561
-rw-r--r--db/schema_migrations/202012021427511
-rw-r--r--db/structure.sql4
-rw-r--r--doc/api/graphql/reference/gitlab_schema.graphql5
-rw-r--r--doc/api/graphql/reference/gitlab_schema.json14
-rw-r--r--doc/ci/README.md2
-rw-r--r--doc/development/agent/identity.md94
-rw-r--r--doc/development/agent/index.md4
-rw-r--r--doc/development/agent/local.md58
-rw-r--r--doc/development/architecture.md2
-rw-r--r--doc/development/experiment_guide/index.md111
-rw-r--r--doc/development/feature_flags/development.md4
-rw-r--r--doc/development/testing_guide/end_to_end/beginners_guide.md6
-rw-r--r--doc/development/what_requires_downtime.md2
-rw-r--r--doc/user/application_security/vulnerabilities/index.md6
-rw-r--r--doc/user/group/saml_sso/index.md8
-rw-r--r--doc/user/permissions.md7
-rw-r--r--doc/user/project/deploy_tokens/index.md3
-rw-r--r--doc/user/project/settings/index.md4
-rw-r--r--lib/gitlab/experimentation.rb46
-rw-r--r--lib/gitlab/experimentation/controller_concern.rb72
-rw-r--r--lib/gitlab/experimentation/experiment.rb5
-rw-r--r--locale/gitlab.pot42
-rw-r--r--spec/controllers/admin/integrations_controller_spec.rb16
-rw-r--r--spec/controllers/admin/users_controller_spec.rb51
-rw-r--r--spec/controllers/groups_controller_spec.rb4
-rw-r--r--spec/controllers/invites_controller_spec.rb2
-rw-r--r--spec/controllers/projects/jobs_controller_spec.rb2
-rw-r--r--spec/controllers/registrations/experience_levels_controller_spec.rb10
-rw-r--r--spec/controllers/root_controller_spec.rb2
-rw-r--r--spec/factories/merge_requests.rb2
-rw-r--r--spec/features/admin/users/user_spec.rb4
-rw-r--r--spec/features/projects/jobs_spec.rb2
-rw-r--r--spec/features/registrations/experience_level_spec.rb2
-rw-r--r--spec/frontend/integrations/edit/store/actions_spec.js30
-rw-r--r--spec/frontend/integrations/edit/store/mutations_spec.js16
-rw-r--r--spec/frontend/pipelines/unwrapping_utils_spec.js127
-rw-r--r--spec/graphql/types/countable_connection_type_spec.rb2
-rw-r--r--spec/graphql/types/merge_request_connection_type_spec.rb11
-rw-r--r--spec/helpers/services_helper_spec.rb53
-rw-r--r--spec/lib/gitlab/experimentation/controller_concern_spec.rb140
-rw-r--r--spec/lib/gitlab/experimentation/experiment_spec.rb8
-rw-r--r--spec/lib/gitlab/experimentation_spec.rb139
-rw-r--r--spec/mailers/notify_spec.rb2
-rw-r--r--spec/migrations/populate_remaining_missing_dismissal_information_for_vulnerabilities_spec.rb31
-rw-r--r--spec/models/merge_request_spec.rb71
-rw-r--r--spec/policies/global_policy_spec.rb18
-rw-r--r--spec/services/members/invitation_reminder_email_service_spec.rb4
-rw-r--r--spec/services/notification_service_spec.rb14
-rw-r--r--spec/services/users/reject_service_spec.rb54
-rw-r--r--spec/support/helpers/stub_experiments.rb16
-rw-r--r--spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb4
93 files changed, 1492 insertions, 315 deletions
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index 32366c5068f..ac8a64d5f3b 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -61,7 +61,13 @@ export default {
},
},
methods: {
- ...mapActions(['setOverride', 'setIsSaving', 'setIsTesting', 'setIsResetting']),
+ ...mapActions([
+ 'setOverride',
+ 'setIsSaving',
+ 'setIsTesting',
+ 'setIsResetting',
+ 'fetchResetIntegration',
+ ]),
onSaveClick() {
this.setIsSaving(true);
eventHub.$emit('saveIntegration');
@@ -70,7 +76,9 @@ export default {
this.setIsTesting(true);
eventHub.$emit('testIntegration');
},
- onResetClick() {},
+ onResetClick() {
+ this.fetchResetIntegration();
+ },
},
};
</script>
diff --git a/app/assets/javascripts/integrations/edit/store/actions.js b/app/assets/javascripts/integrations/edit/store/actions.js
index 097304be242..421917b720a 100644
--- a/app/assets/javascripts/integrations/edit/store/actions.js
+++ b/app/assets/javascripts/integrations/edit/store/actions.js
@@ -1,3 +1,5 @@
+import axios from 'axios';
+import { refreshCurrentPage } from '~/lib/utils/url_utility';
import * as types from './mutation_types';
export const setOverride = ({ commit }, override) => commit(types.SET_OVERRIDE, override);
@@ -5,3 +7,22 @@ export const setIsSaving = ({ commit }, isSaving) => commit(types.SET_IS_SAVING,
export const setIsTesting = ({ commit }, isTesting) => commit(types.SET_IS_TESTING, isTesting);
export const setIsResetting = ({ commit }, isResetting) =>
commit(types.SET_IS_RESETTING, isResetting);
+
+export const requestResetIntegration = ({ commit }) => {
+ commit(types.REQUEST_RESET_INTEGRATION);
+};
+export const receiveResetIntegrationSuccess = () => {
+ refreshCurrentPage();
+};
+export const receiveResetIntegrationError = ({ commit }) => {
+ commit(types.RECEIVE_RESET_INTEGRATION_ERROR);
+};
+
+export const fetchResetIntegration = ({ dispatch, getters }) => {
+ dispatch('requestResetIntegration');
+
+ return axios
+ .post(getters.propsSource.resetPath, { params: { format: 'json' } })
+ .then(() => dispatch('receiveResetIntegrationSuccess'))
+ .catch(() => dispatch('receiveResetIntegrationError'));
+};
diff --git a/app/assets/javascripts/integrations/edit/store/mutation_types.js b/app/assets/javascripts/integrations/edit/store/mutation_types.js
index 2a84408f658..54928148b22 100644
--- a/app/assets/javascripts/integrations/edit/store/mutation_types.js
+++ b/app/assets/javascripts/integrations/edit/store/mutation_types.js
@@ -2,3 +2,6 @@ export const SET_OVERRIDE = 'SET_OVERRIDE';
export const SET_IS_SAVING = 'SET_IS_SAVING';
export const SET_IS_TESTING = 'SET_IS_TESTING';
export const SET_IS_RESETTING = 'SET_IS_RESETTING';
+
+export const REQUEST_RESET_INTEGRATION = 'REQUEST_RESET_INTEGRATION';
+export const RECEIVE_RESET_INTEGRATION_ERROR = 'RECEIVE_RESET_INTEGRATION_ERROR';
diff --git a/app/assets/javascripts/integrations/edit/store/mutations.js b/app/assets/javascripts/integrations/edit/store/mutations.js
index 07e3e25ccf0..826757e665b 100644
--- a/app/assets/javascripts/integrations/edit/store/mutations.js
+++ b/app/assets/javascripts/integrations/edit/store/mutations.js
@@ -13,4 +13,10 @@ export default {
[types.SET_IS_RESETTING](state, isResetting) {
state.isResetting = isResetting;
},
+ [types.REQUEST_RESET_INTEGRATION](state) {
+ state.isResetting = true;
+ },
+ [types.RECEIVE_RESET_INTEGRATION_ERROR](state) {
+ state.isResetting = false;
+ },
};
diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js
index 698bade19fe..df3615772ce 100644
--- a/app/assets/javascripts/pipelines/components/graph/utils.js
+++ b/app/assets/javascripts/pipelines/components/graph/utils.js
@@ -1,3 +1,9 @@
+import { unwrapStagesWithNeeds } from '../unwrapping_utils';
+
+const addMulti = (mainId, pipeline) => {
+ return { ...pipeline, multiproject: mainId !== pipeline.id };
+};
+
const unwrapPipelineData = (mainPipelineId, data) => {
if (!data?.project?.pipeline) {
return null;
@@ -10,35 +16,13 @@ const unwrapPipelineData = (mainPipelineId, data) => {
stages: { nodes: stages },
} = data.project.pipeline;
- const unwrappedNestedGroups = stages.map(stage => {
- const {
- groups: { nodes: groups },
- } = stage;
- return { ...stage, groups };
- });
-
- const nodes = unwrappedNestedGroups.map(({ name, status, groups }) => {
- const groupsWithJobs = groups.map(group => {
- const jobs = group.jobs.nodes.map(job => {
- const { needs } = job;
- return { ...job, needs: needs.nodes.map(need => need.name) };
- });
-
- return { ...group, jobs };
- });
-
- return { name, status, groups: groupsWithJobs };
- });
-
- const addMulti = pipeline => {
- return { ...pipeline, multiproject: mainPipelineId !== pipeline.id };
- };
+ const nodes = unwrapStagesWithNeeds(stages);
return {
id,
stages: nodes,
- upstream: upstream ? [upstream].map(addMulti) : [],
- downstream: downstream ? downstream.map(addMulti) : [],
+ upstream: upstream ? [upstream].map(addMulti.bind(null, mainPipelineId)) : [],
+ downstream: downstream ? downstream.map(addMulti.bind(null, mainPipelineId)) : [],
};
};
diff --git a/app/assets/javascripts/pipelines/components/unwrapping_utils.js b/app/assets/javascripts/pipelines/components/unwrapping_utils.js
new file mode 100644
index 00000000000..99934cd5014
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/unwrapping_utils.js
@@ -0,0 +1,36 @@
+const unwrapGroups = stages => {
+ return stages.map(stage => {
+ const {
+ groups: { nodes: groups },
+ } = stage;
+ return { ...stage, groups };
+ });
+};
+
+const unwrapNodesWithName = (jobArray, prop, field = 'name') => {
+ return jobArray.map(job => {
+ return { ...job, [prop]: job[prop].nodes.map(item => item[field]) };
+ });
+};
+
+const unwrapJobWithNeeds = denodedJobArray => {
+ return unwrapNodesWithName(denodedJobArray, 'needs');
+};
+
+const unwrapStagesWithNeeds = denodedStages => {
+ const unwrappedNestedGroups = unwrapGroups(denodedStages);
+
+ const nodes = unwrappedNestedGroups.map(node => {
+ const { groups } = node;
+ const groupsWithJobs = groups.map(group => {
+ const jobs = unwrapJobWithNeeds(group.jobs.nodes);
+ return { ...group, jobs };
+ });
+
+ return { ...node, groups: groupsWithJobs };
+ });
+
+ return nodes;
+};
+
+export { unwrapGroups, unwrapNodesWithName, unwrapJobWithNeeds, unwrapStagesWithNeeds };
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 2d0bb0bfebc..3fe972d1917 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -72,6 +72,16 @@ class Admin::UsersController < Admin::ApplicationController
end
end
+ def reject
+ result = Users::RejectService.new(current_user).execute(user)
+
+ if result[:status] == :success
+ redirect_to admin_users_path, status: :found, notice: _("You've rejected %{user}" % { user: user.name })
+ else
+ redirect_back_or_admin_user(alert: result[:message])
+ end
+ end
+
def activate
return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user must be unblocked to be activated")) if user.blocked?
diff --git a/app/controllers/concerns/integrations_actions.rb b/app/controllers/concerns/integrations_actions.rb
index 8e9b038437d..86968b99ce6 100644
--- a/app/controllers/concerns/integrations_actions.rb
+++ b/app/controllers/concerns/integrations_actions.rb
@@ -43,6 +43,12 @@ module IntegrationsActions
render json: {}, status: :ok
end
+ def reset
+ flash[:notice] = s_('Integrations|This integration, and inheriting projects were reset.')
+
+ render json: {}, status: :ok
+ end
+
private
def integrations_enabled?
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 26fc1c11f6d..4224802b2b0 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -109,15 +109,6 @@ class InvitesController < ApplicationController
end
def track_invitation_reminders_experiment(action)
- return unless Gitlab::Experimentation.enabled?(:invitation_reminders)
-
- property = Gitlab::Experimentation.enabled_for_attribute?(:invitation_reminders, member.invite_email) ? 'experimental_group' : 'control_group'
-
- Gitlab::Tracking.event(
- Gitlab::Experimentation.experiment(:invitation_reminders).tracking_category,
- action,
- property: property,
- label: Digest::MD5.hexdigest(member.to_global_id.to_s)
- )
+ track_experiment_event(:invitation_reminders, action, subject: member)
end
end
diff --git a/app/graphql/types/merge_request_connection_type.rb b/app/graphql/types/merge_request_connection_type.rb
new file mode 100644
index 00000000000..da06bb86929
--- /dev/null
+++ b/app/graphql/types/merge_request_connection_type.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ class MergeRequestConnectionType < Types::CountableConnectionType
+ field :total_time_to_merge, GraphQL::FLOAT_TYPE, null: true,
+ description: 'Total sum of time to merge, in seconds, for the collection of merge requests'
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def total_time_to_merge
+ object.items.reorder(nil).total_time_to_merge
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+end
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index 91cc26868fb..bc8871cfa39 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -4,7 +4,7 @@ module Types
class MergeRequestType < BaseObject
graphql_name 'MergeRequest'
- connection_type_class(Types::CountableConnectionType)
+ connection_type_class(Types::MergeRequestConnectionType)
implements(Types::Notes::NoteableType)
implements(Types::CurrentUserTodos)
diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb
index 96eb14be4b4..b7e11d7cbb6 100644
--- a/app/helpers/services_helper.rb
+++ b/app/helpers/services_helper.rb
@@ -75,7 +75,15 @@ module ServicesHelper
end
end
- def integration_form_data(integration)
+ def scoped_reset_integration_path(integration, group: nil)
+ if group.present?
+ reset_group_settings_integration_path(group, integration)
+ else
+ reset_admin_application_settings_integration_path(integration)
+ end
+ end
+
+ def integration_form_data(integration, group: nil)
{
id: integration.id,
show_active: integration.show_active_box?.to_s,
@@ -94,7 +102,7 @@ module ServicesHelper
cancel_path: scoped_integrations_path,
can_test: integration.can_test?.to_s,
test_path: scoped_test_integration_path(integration),
- reset_path: ''
+ reset_path: reset_integrations?(group: group) ? scoped_reset_integration_path(integration, group: group) : ''
}
end
@@ -122,6 +130,10 @@ module ServicesHelper
!Gitlab.com?
end
+ def reset_integrations?(group: nil)
+ Feature.enabled?(:reset_integrations, group, type: :development)
+ end
+
extend self
private
diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb
index 0b5a8dfdc24..350b7c2aeec 100644
--- a/app/mailers/emails/members.rb
+++ b/app/mailers/emails/members.rb
@@ -64,11 +64,11 @@ module Emails
layout: 'unknown_user_mailer'
)
- if Gitlab::Experimentation.enabled?(:invitation_reminders)
+ if Gitlab::Experimentation.active?(:invitation_reminders)
Gitlab::Tracking.event(
- Gitlab::Experimentation.experiment(:invitation_reminders).tracking_category,
+ Gitlab::Experimentation.get_experiment(:invitation_reminders).tracking_category,
'sent',
- property: Gitlab::Experimentation.enabled_for_attribute?(:invitation_reminders, member.invite_email) ? 'experimental_group' : 'control_group',
+ property: Gitlab::Experimentation.in_experiment_group?(:invitation_reminders, subject: member.invite_email) ? 'experimental_group' : 'control_group',
label: Digest::MD5.hexdigest(member.to_global_id.to_s)
)
end
diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb
index 6f44b63f8d0..e3c72a343e7 100644
--- a/app/mailers/emails/profile.rb
+++ b/app/mailers/emails/profile.rb
@@ -18,6 +18,14 @@ module Emails
subject: subject(_("GitLab Account Request")))
end
+ def user_admin_rejection_email(name, email)
+ @name = name
+
+ profile_email_with_layout(
+ to: email,
+ subject: subject(_("GitLab account request rejected")))
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def new_ssh_key_email(key_id)
@key = Key.find_by(id: key_id)
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 7687089942a..e3f44492e13 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -339,6 +339,13 @@ class MergeRequest < ApplicationRecord
)
end
+ def self.total_time_to_merge
+ join_metrics
+ .merge(MergeRequest::Metrics.with_valid_time_to_merge)
+ .pluck(MergeRequest::Metrics.time_to_merge_expression)
+ .first
+ end
+
after_save :keep_around_commit, unless: :importing?
alias_attribute :project, :target_project
diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb
index 66bff3f5982..d3fe256fb1b 100644
--- a/app/models/merge_request/metrics.rb
+++ b/app/models/merge_request/metrics.rb
@@ -10,6 +10,11 @@ class MergeRequest::Metrics < ApplicationRecord
scope :merged_after, ->(date) { where(arel_table[:merged_at].gteq(date)) }
scope :merged_before, ->(date) { where(arel_table[:merged_at].lteq(date)) }
+ scope :with_valid_time_to_merge, -> { where(arel_table[:merged_at].gt(arel_table[:created_at])) }
+
+ def self.time_to_merge_expression
+ Arel.sql('EXTRACT(epoch FROM SUM(AGE(merge_request_metrics.merged_at, merge_request_metrics.created_at)))')
+ end
private
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index 686e936808a..b5c1ec0181e 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -99,6 +99,7 @@ class GlobalPolicy < BasePolicy
enable :read_custom_attribute
enable :update_custom_attribute
enable :approve_user
+ enable :reject_user
end
# We can't use `read_statistics` because the user may have different permissions for different projects
diff --git a/app/services/members/invitation_reminder_email_service.rb b/app/services/members/invitation_reminder_email_service.rb
index e589cdc2fa3..7ce6ddb97ef 100644
--- a/app/services/members/invitation_reminder_email_service.rb
+++ b/app/services/members/invitation_reminder_email_service.rb
@@ -25,7 +25,7 @@ module Members
private
def experiment_enabled?
- Gitlab::Experimentation.enabled_for_attribute?(:invitation_reminders, invitation.invite_email)
+ Gitlab::Experimentation.in_experiment_group?(:invitation_reminders, subject: invitation.invite_email)
end
def days_after_invitation_sent
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index b45923a5742..993b1c7a928 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -380,6 +380,10 @@ class NotificationService
end
end
+ def user_admin_rejection(name, email)
+ mailer.user_admin_rejection_email(name, email).deliver_later
+ end
+
# Members
def new_access_request(member)
return true unless member.notifiable?(:subscription)
diff --git a/app/services/users/reject_service.rb b/app/services/users/reject_service.rb
new file mode 100644
index 00000000000..dd72547c688
--- /dev/null
+++ b/app/services/users/reject_service.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Users
+ class RejectService < BaseService
+ def initialize(current_user)
+ @current_user = current_user
+ end
+
+ def execute(user)
+ return error(_('You are not allowed to reject a user')) unless allowed?
+ return error(_('This user does not have a pending request')) unless user.blocked_pending_approval?
+
+ user.delete_async(deleted_by: current_user, params: { hard_delete: true })
+
+ NotificationService.new.user_admin_rejection(user.name, user.email)
+
+ success
+ end
+
+ private
+
+ attr_reader :current_user
+
+ def allowed?
+ can?(current_user, :reject_user)
+ end
+ end
+end
diff --git a/app/views/admin/users/_reject_pending_user.html.haml b/app/views/admin/users/_reject_pending_user.html.haml
new file mode 100644
index 00000000000..17108427330
--- /dev/null
+++ b/app/views/admin/users/_reject_pending_user.html.haml
@@ -0,0 +1,7 @@
+.card.border-danger
+ .card-header.bg-danger.gl-text-white
+ = s_('AdminUsers|This user has requested access')
+ .card-body
+ = render partial: 'admin/users/user_reject_effects'
+ %br
+ = link_to s_('AdminUsers|Reject request'), reject_admin_user_path(user), method: :delete, class: "btn gl-button btn-danger", data: { confirm: s_('AdminUsers|Are you sure?') }
diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml
index 905f2946370..31fd3aea94d 100644
--- a/app/views/admin/users/_user.html.haml
+++ b/app/views/admin/users/_user.html.haml
@@ -37,8 +37,7 @@
- elsif user.blocked?
- if user.blocked_pending_approval?
= link_to s_('AdminUsers|Approve'), approve_admin_user_path(user), method: :put
- %button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_block_data(user, user_block_effects) }
- = s_('AdminUsers|Block')
+ = link_to s_('AdminUsers|Reject'), reject_admin_user_path(user), method: :delete
- else
%button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_unblock_data(user) }
= s_('AdminUsers|Unblock')
@@ -56,7 +55,7 @@
- if user.access_locked?
%li
= link_to _('Unlock'), unlock_admin_user_path(user), method: :put, data: { confirm: _('Are you sure?') }
- - if can?(current_user, :destroy_user, user)
+ - if can?(current_user, :destroy_user, user) && !user.blocked_pending_approval?
%li.divider
- if user.can_be_removed?
%li
diff --git a/app/views/admin/users/_user_reject_effects.html.haml b/app/views/admin/users/_user_reject_effects.html.haml
new file mode 100644
index 00000000000..17b6862b0cc
--- /dev/null
+++ b/app/views/admin/users/_user_reject_effects.html.haml
@@ -0,0 +1,10 @@
+%p
+ = s_('AdminUsers|Rejected users:')
+%ul
+ %li
+ = s_('AdminUsers|Cannot sign in or access instance information')
+ %li
+ = s_('AdminUsers|Will be deleted')
+%p
+ - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path("user/profile/account/delete_account", anchor: "associated-records") }
+ = s_('AdminUsers|For more information, please refer to the %{link_start}user account deletion documentation.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index 85545e33f0c..26f78ea4d6a 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -172,7 +172,7 @@
- if @user.blocked?
- if @user.blocked_pending_approval?
= render 'admin/users/approve_user', user: @user
- = render 'admin/users/block_user', user: @user
+ = render 'admin/users/reject_pending_user', user: @user
- else
.card.border-info
.card-header.gl-bg-blue-500.gl-text-white
@@ -196,52 +196,52 @@
%p This user has been temporarily locked due to excessive number of failed logins. You may manually unlock the account.
%br
= link_to 'Unlock user', unlock_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: 'Are you sure?' }
-
- .card.border-danger
- .card-header.bg-danger.text-white
- = s_('AdminUsers|Delete user')
- .card-body
- - if @user.can_be_removed? && can?(current_user, :destroy_user, @user)
- %p Deleting a user has the following effects:
- = render 'users/deletion_guidance', user: @user
- %br
- %button.delete-user-button.btn.gl-button.btn-danger{ data: { 'gl-modal-action': 'delete',
- delete_user_url: admin_user_path(@user),
- block_user_url: block_admin_user_path(@user),
- username: sanitize_name(@user.name) } }
- = s_('AdminUsers|Delete user')
- - else
- - if @user.solo_owned_groups.present?
- %p
- This user is currently an owner in these groups:
- %strong= @user.solo_owned_groups.map(&:name).join(', ')
+ - if !@user.blocked_pending_approval?
+ .card.border-danger
+ .card-header.bg-danger.text-white
+ = s_('AdminUsers|Delete user')
+ .card-body
+ - if @user.can_be_removed? && can?(current_user, :destroy_user, @user)
+ %p Deleting a user has the following effects:
+ = render 'users/deletion_guidance', user: @user
+ %br
+ %button.delete-user-button.btn.gl-button.btn-danger{ data: { 'gl-modal-action': 'delete',
+ delete_user_url: admin_user_path(@user),
+ block_user_url: block_admin_user_path(@user),
+ username: sanitize_name(@user.name) } }
+ = s_('AdminUsers|Delete user')
+ - else
+ - if @user.solo_owned_groups.present?
+ %p
+ This user is currently an owner in these groups:
+ %strong= @user.solo_owned_groups.map(&:name).join(', ')
+ %p
+ You must transfer ownership or delete these groups before you can delete this user.
+ - else
+ %p
+ You don't have access to delete this user.
+
+ .card.border-danger
+ .card-header.bg-danger.text-white
+ = s_('AdminUsers|Delete user and contributions')
+ .card-body
+ - if can?(current_user, :destroy_user, @user)
%p
- You must transfer ownership or delete these groups before you can delete this user.
+ This option deletes the user and any contributions that
+ would usually be moved to the
+ = succeed "." do
+ = link_to "system ghost user", help_page_path("user/profile/account/delete_account")
+ As well as the user's personal projects, groups owned solely by
+ the user, and projects in them, will also be removed. Commits
+ to other projects are unaffected.
+ %br
+ %button.delete-user-button.btn.gl-button.btn-danger{ data: { 'gl-modal-action': 'delete-with-contributions',
+ delete_user_url: admin_user_path(@user, hard_delete: true),
+ block_user_url: block_admin_user_path(@user),
+ username: @user.name } }
+ = s_('AdminUsers|Delete user and contributions')
- else
%p
You don't have access to delete this user.
- .card.border-danger
- .card-header.bg-danger.text-white
- = s_('AdminUsers|Delete user and contributions')
- .card-body
- - if can?(current_user, :destroy_user, @user)
- %p
- This option deletes the user and any contributions that
- would usually be moved to the
- = succeed "." do
- = link_to "system ghost user", help_page_path("user/profile/account/delete_account")
- As well as the user's personal projects, groups owned solely by
- the user, and projects in them, will also be removed. Commits
- to other projects are unaffected.
- %br
- %button.delete-user-button.btn.gl-button.btn-danger{ data: { 'gl-modal-action': 'delete-with-contributions',
- delete_user_url: admin_user_path(@user, hard_delete: true),
- block_user_url: block_admin_user_path(@user),
- username: @user.name } }
- = s_('AdminUsers|Delete user and contributions')
- - else
- %p
- You don't have access to delete this user.
-
= render partial: 'admin/users/modals'
diff --git a/app/views/notify/user_admin_rejection_email.html.haml b/app/views/notify/user_admin_rejection_email.html.haml
new file mode 100644
index 00000000000..24d6c05fa38
--- /dev/null
+++ b/app/views/notify/user_admin_rejection_email.html.haml
@@ -0,0 +1,5 @@
+= email_default_heading(_('Hello %{name},') % { name: @name })
+%p
+ = _('Your request to join %{host} has been rejected.').html_safe % { host: link_to(root_url, root_url) }
+%p
+ = _('Please contact your GitLab administrator if you think this is an error.')
diff --git a/app/views/notify/user_admin_rejection_email.text.erb b/app/views/notify/user_admin_rejection_email.text.erb
new file mode 100644
index 00000000000..cc676b82934
--- /dev/null
+++ b/app/views/notify/user_admin_rejection_email.text.erb
@@ -0,0 +1,6 @@
+<%= _('Hello %{name},') % { name: @name } %>
+
+<%= _('Your request to join %{host} has been rejected.') % { host: root_url } %>
+
+<%= _('Please contact your GitLab administrator if you think this is an error.') %>
+
diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml
index 647421a8fbe..194e0eb57f2 100644
--- a/app/views/shared/_service_settings.html.haml
+++ b/app/views/shared/_service_settings.html.haml
@@ -9,5 +9,5 @@
.service-settings
- if @default_integration
- .js-vue-default-integration-settings{ data: integration_form_data(@default_integration) }
- .js-vue-integration-settings{ data: integration_form_data(integration) }
+ .js-vue-default-integration-settings{ data: integration_form_data(@default_integration, group: @group) }
+ .js-vue-integration-settings{ data: integration_form_data(integration, group: @group) }
diff --git a/app/workers/member_invitation_reminder_emails_worker.rb b/app/workers/member_invitation_reminder_emails_worker.rb
index 50f583005c0..97aa9c7e065 100644
--- a/app/workers/member_invitation_reminder_emails_worker.rb
+++ b/app/workers/member_invitation_reminder_emails_worker.rb
@@ -8,7 +8,7 @@ class MemberInvitationReminderEmailsWorker # rubocop:disable Scalability/Idempot
urgency :low
def perform
- return unless Gitlab::Experimentation.enabled?(:invitation_reminders)
+ return unless Gitlab::Experimentation.active?(:invitation_reminders)
Member.not_accepted_invitations.not_expired.last_ten_days_excluding_today.find_in_batches do |invitations|
invitations.each do |invitation|
diff --git a/changelogs/unreleased/227765_add_index_vulnerabilities_on_project_id_and_severity.yml b/changelogs/unreleased/227765_add_index_vulnerabilities_on_project_id_and_severity.yml
new file mode 100644
index 00000000000..6ef8d608a69
--- /dev/null
+++ b/changelogs/unreleased/227765_add_index_vulnerabilities_on_project_id_and_severity.yml
@@ -0,0 +1,6 @@
+---
+title: Add index for the `vulnerabilities` table on `project_id`, `state`, and `severity`
+ columns
+merge_request: 48930
+author:
+type: added
diff --git a/changelogs/unreleased/262112_populate_remaining_dismissal_information.yml b/changelogs/unreleased/262112_populate_remaining_dismissal_information.yml
new file mode 100644
index 00000000000..c4eea32f645
--- /dev/null
+++ b/changelogs/unreleased/262112_populate_remaining_dismissal_information.yml
@@ -0,0 +1,5 @@
+---
+title: Add migration to populate remaining dismissal information for vulnerabilities
+merge_request: 48472
+author:
+type: added
diff --git a/changelogs/unreleased/280596-user-admin-approval-reject-user.yml b/changelogs/unreleased/280596-user-admin-approval-reject-user.yml
new file mode 100644
index 00000000000..bffee96e3e2
--- /dev/null
+++ b/changelogs/unreleased/280596-user-admin-approval-reject-user.yml
@@ -0,0 +1,5 @@
+---
+title: Email user when registration request is rejected
+merge_request: 48185
+author:
+type: added
diff --git a/changelogs/unreleased/add-mean-time-to-merge-be.yml b/changelogs/unreleased/add-mean-time-to-merge-be.yml
new file mode 100644
index 00000000000..d497ea8db38
--- /dev/null
+++ b/changelogs/unreleased/add-mean-time-to-merge-be.yml
@@ -0,0 +1,5 @@
+---
+title: Add merge requests total time to merge field to the GraphQL API
+merge_request: 46040
+author:
+type: added
diff --git a/config/feature_flags/development/reset_integrations.yml b/config/feature_flags/development/reset_integrations.yml
new file mode 100644
index 00000000000..332fbc96ef5
--- /dev/null
+++ b/config/feature_flags/development/reset_integrations.yml
@@ -0,0 +1,8 @@
+---
+name: reset_integrations
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/47546
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/283875
+milestone: '13.7'
+type: development
+group: group::ecosystem
+default_enabled: false
diff --git a/config/routes/admin.rb b/config/routes/admin.rb
index 2db2caafcd8..71a927f59b9 100644
--- a/config/routes/admin.rb
+++ b/config/routes/admin.rb
@@ -18,6 +18,7 @@ namespace :admin do
put :unlock
put :confirm
put :approve
+ delete :reject
post :impersonate
patch :disable_two_factor
delete 'remove/:email_id', action: 'remove_email', as: 'remove_email'
@@ -126,6 +127,7 @@ namespace :admin do
resources :integrations, only: [:edit, :update] do
member do
put :test
+ post :reset
end
end
diff --git a/config/routes/group.rb b/config/routes/group.rb
index e90be482bbd..3b52aae52e2 100644
--- a/config/routes/group.rb
+++ b/config/routes/group.rb
@@ -46,6 +46,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
resources :integrations, only: [:index, :edit, :update] do
member do
put :test
+ post :reset
end
end
end
diff --git a/db/migrate/20201104142036_add_index_to_merge_request_metrics_target_project_id.rb b/db/migrate/20201104142036_add_index_to_merge_request_metrics_target_project_id.rb
new file mode 100644
index 00000000000..348a3387b6f
--- /dev/null
+++ b/db/migrate/20201104142036_add_index_to_merge_request_metrics_target_project_id.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class AddIndexToMergeRequestMetricsTargetProjectId < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ INDEX_NAME = 'index_mr_metrics_on_target_project_id_merged_at_time_to_merge'
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :merge_request_metrics, [:target_project_id, :merged_at, :created_at], where: 'merged_at > created_at', name: INDEX_NAME
+ end
+
+ def down
+ remove_concurrent_index_by_name(:merge_request_metrics, INDEX_NAME)
+ end
+end
diff --git a/db/migrate/20201201175656_add_index_vulnerabilities_on_project_id_and_state_and_severity.rb b/db/migrate/20201201175656_add_index_vulnerabilities_on_project_id_and_state_and_severity.rb
new file mode 100644
index 00000000000..d0e8920d7a5
--- /dev/null
+++ b/db/migrate/20201201175656_add_index_vulnerabilities_on_project_id_and_state_and_severity.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class AddIndexVulnerabilitiesOnProjectIdAndStateAndSeverity < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ INDEX_NAME = 'index_vulnerabilities_on_project_id_and_state_and_severity'
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :vulnerabilities, [:project_id, :state, :severity], name: INDEX_NAME
+ end
+
+ def down
+ remove_concurrent_index_by_name :vulnerabilities, INDEX_NAME
+ end
+end
diff --git a/db/migrate/20201202142751_drop_index_vulnerabilities_on_project_id.rb b/db/migrate/20201202142751_drop_index_vulnerabilities_on_project_id.rb
new file mode 100644
index 00000000000..c1ca32a1978
--- /dev/null
+++ b/db/migrate/20201202142751_drop_index_vulnerabilities_on_project_id.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class DropIndexVulnerabilitiesOnProjectId < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ INDEX_NAME = 'index_vulnerabilities_on_project_id'
+
+ disable_ddl_transaction!
+
+ def up
+ remove_concurrent_index_by_name :vulnerabilities, INDEX_NAME
+ end
+
+ def down
+ add_concurrent_index :vulnerabilities, :project_id, name: INDEX_NAME
+ end
+end
diff --git a/db/post_migrate/20201124122817_populate_remaining_missing_dismissal_information_for_vulnerabilities.rb b/db/post_migrate/20201124122817_populate_remaining_missing_dismissal_information_for_vulnerabilities.rb
new file mode 100644
index 00000000000..9dc41d17231
--- /dev/null
+++ b/db/post_migrate/20201124122817_populate_remaining_missing_dismissal_information_for_vulnerabilities.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class PopulateRemainingMissingDismissalInformationForVulnerabilities < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ Gitlab::BackgroundMigration.steal('PopulateMissingVulnerabilityDismissalInformation')
+
+ ::Gitlab::BackgroundMigration::PopulateMissingVulnerabilityDismissalInformation::Vulnerability.broken.each_batch(of: 100) do |batch, index|
+ vulnerability_ids = batch.pluck(:id)
+
+ ::Gitlab::BackgroundMigration::PopulateMissingVulnerabilityDismissalInformation.new.perform(*vulnerability_ids)
+ end
+ end
+
+ def down
+ # no-op
+ end
+end
diff --git a/db/schema_migrations/20201104142036 b/db/schema_migrations/20201104142036
new file mode 100644
index 00000000000..9c41c9c9724
--- /dev/null
+++ b/db/schema_migrations/20201104142036
@@ -0,0 +1 @@
+bde71afbe34006eedbd97ac457df31b247fc89a572ca8900c60b16c4d6a8ef93 \ No newline at end of file
diff --git a/db/schema_migrations/20201124122817 b/db/schema_migrations/20201124122817
new file mode 100644
index 00000000000..d7fd2707dd3
--- /dev/null
+++ b/db/schema_migrations/20201124122817
@@ -0,0 +1 @@
+1113642dfc9069dcea01ac12b1653dfcf67b3aea449538e9747d3bc857ce88d8 \ No newline at end of file
diff --git a/db/schema_migrations/20201201175656 b/db/schema_migrations/20201201175656
new file mode 100644
index 00000000000..52c98e28eee
--- /dev/null
+++ b/db/schema_migrations/20201201175656
@@ -0,0 +1 @@
+54ed18361a28d0b750cbbdb3bfb53b7e4bbe3d1d7264de51522796d3bd15f7a5 \ No newline at end of file
diff --git a/db/schema_migrations/20201202142751 b/db/schema_migrations/20201202142751
new file mode 100644
index 00000000000..cb0013a5d70
--- /dev/null
+++ b/db/schema_migrations/20201202142751
@@ -0,0 +1 @@
+cb11dc9996b1706feaa8a53f96cbaa6209a4d07b3be9e88ebc3d1e1ada561287 \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 8f4774d6467..b052178164f 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -21631,6 +21631,8 @@ CREATE UNIQUE INDEX index_mr_context_commits_on_merge_request_id_and_sha ON merg
CREATE INDEX index_mr_metrics_on_target_project_id_merged_at_nulls_last ON merge_request_metrics USING btree (target_project_id, merged_at DESC NULLS LAST, id DESC);
+CREATE INDEX index_mr_metrics_on_target_project_id_merged_at_time_to_merge ON merge_request_metrics USING btree (target_project_id, merged_at, created_at) WHERE (merged_at > created_at);
+
CREATE UNIQUE INDEX index_namespace_aggregation_schedules_on_namespace_id ON namespace_aggregation_schedules USING btree (namespace_id);
CREATE INDEX index_namespace_onboarding_actions_on_namespace_id ON namespace_onboarding_actions USING btree (namespace_id);
@@ -22557,7 +22559,7 @@ CREATE INDEX index_vulnerabilities_on_last_edited_by_id ON vulnerabilities USING
CREATE INDEX index_vulnerabilities_on_milestone_id ON vulnerabilities USING btree (milestone_id);
-CREATE INDEX index_vulnerabilities_on_project_id ON vulnerabilities USING btree (project_id);
+CREATE INDEX index_vulnerabilities_on_project_id_and_state_and_severity ON vulnerabilities USING btree (project_id, state, severity);
CREATE INDEX index_vulnerabilities_on_resolved_by_id ON vulnerabilities USING btree (resolved_by_id);
diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql
index cc3a1019c48..c682baed752 100644
--- a/doc/api/graphql/reference/gitlab_schema.graphql
+++ b/doc/api/graphql/reference/gitlab_schema.graphql
@@ -13050,6 +13050,11 @@ type MergeRequestConnection {
Information to aid in pagination.
"""
pageInfo: PageInfo!
+
+ """
+ Total sum of time to merge, in seconds, for the collection of merge requests
+ """
+ totalTimeToMerge: Float
}
"""
diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json
index 7066f2b5dc6..a283e437b23 100644
--- a/doc/api/graphql/reference/gitlab_schema.json
+++ b/doc/api/graphql/reference/gitlab_schema.json
@@ -36011,6 +36011,20 @@
},
"isDeprecated": false,
"deprecationReason": null
+ },
+ {
+ "name": "totalTimeToMerge",
+ "description": "Total sum of time to merge, in seconds, for the collection of merge requests",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "Float",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
}
],
"inputFields": null,
diff --git a/doc/ci/README.md b/doc/ci/README.md
index e860352b310..8c1ea3b75ea 100644
--- a/doc/ci/README.md
+++ b/doc/ci/README.md
@@ -73,7 +73,7 @@ to your needs:
![Use a `.gitlab-ci.yml` template](img/add_file_template_11_10.png)
-While building your `.gitlab-ci.yml`, you can use the [CI/CD configuration visualization](yaml/visualization.md) to facilate your writing experience.
+While building your `.gitlab-ci.yml`, you can use the [CI/CD configuration visualization](yaml/visualization.md) to facilitate your writing experience.
For a broader overview, see the [CI/CD getting started](quick_start/README.md) guide.
diff --git a/doc/development/agent/identity.md b/doc/development/agent/identity.md
new file mode 100644
index 00000000000..3624bf15c26
--- /dev/null
+++ b/doc/development/agent/identity.md
@@ -0,0 +1,94 @@
+---
+stage: Configure
+group: Configure
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
+---
+
+# Kubernetes Agent identity and authentication **(PREMIUM ONLY)**
+
+This page uses the word `agent` to describe the concept of the
+GitLab Kubernetes Agent. The program that implements the concept is called `agentk`.
+Read the
+[architecture page](https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent/-/blob/master/doc/architecture.md)
+for more information.
+
+## Agent identity and name
+
+In a GitLab installation, each agent must have a unique, immutable name. This
+name must be unique in the project the agent is attached to, and this name must
+follow the [DNS label standard from RFC 1123](https://tools.ietf.org/html/rfc1123).
+The name must:
+
+- Contain at most 63 characters.
+- Contain only lowercase alphanumeric characters or `-`.
+- Start with an alphanumeric character.
+- End with an alphanumeric character.
+
+Kubernetes uses the
+[same naming restriction](https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names)
+for some names.
+
+The regex for names is: `/\A[a-z0-9]([-a-z0-9]*[a-z0-9])?\z/`.
+
+## Multiple agents in a cluster
+
+A Kubernetes cluster may have 0 or more agents running in it. Each agent likely
+has a different configuration. Some may enable features A and B, and some may
+enable features B and C. This flexibility enables different groups of people to
+use different features of the agent in the same cluster.
+
+For example, [Priyanka (Platform Engineer)](https://about.gitlab.com/handbook/marketing/product-marketing/roles-personas/#priyanka-platform-engineer)
+may want to use cluster-wide features of the agent, while
+[Sasha (Software Developer)](https://about.gitlab.com/handbook/marketing/product-marketing/roles-personas/#sasha-software-developer)
+uses the agent that only has access to a particular namespace.
+
+Each agent is likely running using a
+[`ServiceAccount`](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/),
+a distinct Kubernetes identity, with a distinct set of permissions attached to it.
+These permissions enable the agent administrator to follow the
+[principle of least privilege](https://en.wikipedia.org/wiki/Principle_of_least_privilege)
+and minimize the permissions each particular agent needs.
+
+## Kubernetes Agent authentication
+
+When adding a new agent, GitLab provides the user with a bearer access token. The
+agent uses this token to authenticate with GitLab. This token is a random string
+and does not encode any information in it, but it is secret and must
+be treated with care. Store it as a `Secret` in Kubernetes.
+
+Each agent can have 0 or more tokens in GitLab's database. Having several valid
+tokens helps you rotate tokens without needing to re-register an agent. Each token
+record in the database has the following fields:
+
+- Agent identity it belongs to.
+- Token value. Encrypted at rest.
+- Creation time.
+- Who created it.
+- Revocation flag to mark token as revoked.
+- Revocation time.
+- Who revoked it.
+- A text field to store any comments the administrator may want to make about the token for future self.
+
+Tokens can be managed by users with `maintainer` and higher level of
+[permissions](../../user/permissions.md).
+
+Tokens are immutable, and only the following fields can be updated:
+
+- Revocation flag. Can only be updated to `true` once, but immutable after that.
+- Revocation time. Set to the current time when revocation flag is set, but immutable after that.
+- Comments field. Can be updated any number of times, including after the token has been revoked.
+
+The agent sends its token, along with each request, to GitLab to authenticate itself.
+For each request, GitLab checks the token's validity:
+
+- Does the token exist in the database?
+- Has the token been revoked?
+
+This information may be cached for some time to reduce load on the database.
+
+## Kubernetes Agent authorization
+
+GitLab provides the following information in its response for a given Agent access token:
+
+- Agent configuration Git repository. (The agent doesn't support per-folder authorization.)
+- Agent name.
diff --git a/doc/development/agent/index.md b/doc/development/agent/index.md
index 4361863daeb..dd09a779318 100644
--- a/doc/development/agent/index.md
+++ b/doc/development/agent/index.md
@@ -72,6 +72,10 @@ graph TB
- (Optional) Sending notifications through ActionCable for events received from `agentk`.
- Polling manifest repositories for [GitOps support](https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent/-/blob/master/doc/gitops.md) by communicating with Gitaly.
+<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
+To learn more about how the repository is structured, see
+[GitLab Kubernetes Agent repository overview](https://www.youtube.com/watch?v=j8CyaCWroUY).
+
## Guiding principles
GitLab prefers to add logic into `kas` rather than `agentk`. `agentk` should be kept
diff --git a/doc/development/agent/local.md b/doc/development/agent/local.md
new file mode 100644
index 00000000000..2af53d0b3bd
--- /dev/null
+++ b/doc/development/agent/local.md
@@ -0,0 +1,58 @@
+---
+stage: Configure
+group: Configure
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
+---
+
+# Run the Kubernetes Agent locally **(PREMIUM ONLY)**
+
+You can run `kas` and `agentk` locally to test the [Kubernetes Agent](index.md) yourself.
+
+1. Create a `cfg.yaml` file from the contents of
+ [`kas_config_example.yaml`](https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent/-/blob/master/doc/kas_config_example.yaml), or this example:
+
+ ```yaml
+ listen_agent:
+ network: tcp
+ address: 127.0.0.1:8150
+ websocket: false
+ gitlab:
+ address: http://localhost:3000
+ authentication_secret_file: /Users/tkuah/code/ee-gdk/gitlab/.gitlab_kas_secret
+ agent:
+ gitops:
+ poll_period: "10s"
+ ```
+
+1. Create a `token.txt`. This is the token for
+ [the agent you created](../../user/clusters/agent/index.md#create-an-agent-record-in-gitlab). This file must not contain a newline character. You can create the file with this command:
+
+ ```shell
+ echo -n "<TOKEN>" > token.txt
+ ```
+
+1. Start the binaries with the following commands:
+
+ ```shell
+ # Need GitLab to start
+ gdk start
+ # Stop GDK's version of kas
+ gdk stop gitlab-k8s-agent
+
+ # Start kas
+ bazel run //cmd/kas -- --configuration-file="$(pwd)/cfg.yaml"
+ ```
+
+1. In a new terminal window, run this command to start agentk:
+
+ ```shell
+ bazel run //cmd/agentk -- --kas-address=grpc://127.0.0.1:8150 --token-file="$(pwd)/token.txt"
+ ```
+
+You can also inspect the
+[Makefile](https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent/-/blob/master/Makefile)
+for more targets.
+
+<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
+To learn more about how the repository is structured, see
+[GitLab Kubernetes Agent repository overview](https://www.youtube.com/watch?v=j8CyaCWroUY).
diff --git a/doc/development/architecture.md b/doc/development/architecture.md
index 3e6cb6752f1..a056ab595f4 100644
--- a/doc/development/architecture.md
+++ b/doc/development/architecture.md
@@ -434,7 +434,7 @@ GitLab CI/CD is the open-source continuous integration service included with Git
#### GitLab Shell
-- [Project page](https://gitlab.com/gitlab-org/gitlab-shell/blob/master/README.md)
+- [Project page](https://gitlab.com/gitlab-org/gitlab-shell/-/blob/main/README.md)
- Configuration:
- [Omnibus](https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/files/gitlab-config-template/gitlab.rb.template)
- [Charts](https://docs.gitlab.com/charts/charts/gitlab/gitlab-shell/)
diff --git a/doc/development/experiment_guide/index.md b/doc/development/experiment_guide/index.md
index d01aa1a46c3..bee6b4015f6 100644
--- a/doc/development/experiment_guide/index.md
+++ b/doc/development/experiment_guide/index.md
@@ -56,59 +56,90 @@ addressed.
1. Use the experiment in the code.
+ Experiments can be performed on a `subject`. The `subject` that gets provided needs to respond to `to_global_id` or `to_s`.
+ The resulting string is bucketed and assigned to either the control or the experimental group. It's therefore necessary to always provide the same `subject` for an experiment to have the same experience.
+
- Use this standard for the experiment in a controller:
- ```ruby
- class RegistrationController < ApplicationController
+ Experiment run for a user:
+
+ ```ruby
+ class ProjectController < ApplicationController
def show
# experiment_enabled?(:experiment_key) is also available in views and helpers
+ if experiment_enabled?(:signup_flow, subject: current_user)
+ # render the experiment
+ else
+ # render the original version
+ end
+ end
+ end
+ ```
+
+ or experiment run for a namespace:
+
+ ```ruby
+ if experiment_enabled?(:signup_flow, subject: namespace)
+ # experiment code
+ else
+ # control code
+ end
+ ```
+
+ When no subject is given, it falls back to a cookie that gets set and is consistent until
+ the cookie gets deleted.
+
+ ```ruby
+ class RegistrationController < ApplicationController
+ def show
+ # falls back to a cookie
if experiment_enabled?(:signup_flow)
# render the experiment
else
# render the original version
end
end
- end
- ```
+ end
+ ```
- Make the experiment available to the frontend in a controller:
- ```ruby
- before_action do
- push_frontend_experiment(:signup_flow)
- end
- ```
+ ```ruby
+ before_action do
+ push_frontend_experiment(:signup_flow, subject: current_user)
+ end
+ ```
- The above checks whether the experiment is enabled and push the result to the frontend.
+ The above checks whether the experiment is enabled and pushes the result to the frontend.
- You can check the state of the feature flag in JavaScript:
+ You can check the state of the feature flag in JavaScript:
- ```javascript
- import { isExperimentEnabled } from '~/experimentation';
+ ```javascript
+ import { isExperimentEnabled } from '~/experimentation';
- if ( isExperimentEnabled('signupFlow') ) {
- // ...
- }
- ```
+ if ( isExperimentEnabled('signupFlow') ) {
+ // ...
+ }
+ ```
- It is also possible to run an experiment outside of the controller scope, for example in a worker:
- ```ruby
- class SomeWorker
- def perform
- # Check if the experiment is enabled at all (the percentage_of_time_value > 0)
- return unless Gitlab::Experimentation.enabled?(:experiment_key)
-
- # Since we cannot access cookies in a worker, we need to bucket models based on a unique, unchanging attribute instead.
- # Use the following method to check if the experiment is enabled for a certain attribute, for example a username or email address:
- if Gitlab::Experimentation.enabled_for_attribute?(:experiment_key, some_attribute)
- # execute experimental code
- else
- # execute control code
- end
- end
- end
- ```
+ ```ruby
+ class SomeWorker
+ def perform
+ # Check if the experiment is active at all (the percentage_of_time_value > 0)
+ return unless Gitlab::Experimentation.active?(:experiment_key)
+
+ # Since we cannot access cookies in a worker, we need to bucket models based on a unique, unchanging attribute instead.
+ # It is therefore necessery to always provide the same subject.
+ if Gitlab::Experimentation.in_experiment_group?(:experiment_key, subject: user)
+ # execute experimental code
+ else
+ # execute control code
+ end
+ end
+ end
+ ```
### Implement the tracking events
@@ -122,7 +153,7 @@ The framework provides the following helper method that is available in controll
```ruby
before_action do
- track_experiment_event(:signup_flow, 'action', 'value')
+ track_experiment_event(:signup_flow, 'action', 'value', subject: current_user)
end
```
@@ -132,7 +163,7 @@ Which can be tested as follows:
context 'when the experiment is active and the user is in the experimental group' do
before do
stub_experiment(signup_flow: true)
- stub_experiment_for_user(signup_flow: true)
+ stub_experiment_for_subject(signup_flow: true)
end
it 'tracks an event', :snowplow do
@@ -155,8 +186,8 @@ The framework provides the following helper method that is available in controll
```ruby
before_action do
- push_frontend_experiment(:signup_flow)
- frontend_experimentation_tracking_data(:signup_flow, 'action', 'value')
+ push_frontend_experiment(:signup_flow, subject: current_user)
+ frontend_experimentation_tracking_data(:signup_flow, 'action', 'value', subject: current_user)
end
```
@@ -255,7 +286,7 @@ Along with the tracking of backend and frontend events and the [recording of exp
- **Experimental experience:** Show an in-product nudge to see if it causes more people to sign up for trials.
- **Conversion event:** The user starts a trial.
-The `record_experiment_conversion_event` helper method is available to all controllers, and enables us to easily record the conversion event for the current user, regardless of whether they are in the control or experimental group:
+The `record_experiment_conversion_event` helper method is available to all controllers. It enables us to record the conversion event for the current user, regardless of whether they are in the control or experimental group:
```ruby
before_action do
@@ -296,7 +327,7 @@ context 'when the experiment is active' do
context 'when the user is in the experimental group' do
before do
- stub_experiment_for_user(signup_flow: true)
+ stub_experiment_for_subject(signup_flow: true)
end
it { is_expected.to do_experimental_thing }
@@ -304,7 +335,7 @@ context 'when the experiment is active' do
context 'when the user is in the control group' do
before do
- stub_experiment_for_user(signup_flow: false)
+ stub_experiment_for_subject(signup_flow: false)
end
it { is_expected.to do_control_thing }
diff --git a/doc/development/feature_flags/development.md b/doc/development/feature_flags/development.md
index a84536eac61..c195173f450 100644
--- a/doc/development/feature_flags/development.md
+++ b/doc/development/feature_flags/development.md
@@ -503,10 +503,6 @@ is persisted.
Make sure behavior under feature flag doesn't go untested in some non-specific contexts.
-See the
-[testing guide](../testing_guide/best_practices.md#feature-flags-in-tests)
-for information and examples on how to stub feature flags in tests.
-
### `stub_feature_flags: false`
This disables a memory-stubbed flipper, and uses `Flipper::Adapters::ActiveRecord`
diff --git a/doc/development/testing_guide/end_to_end/beginners_guide.md b/doc/development/testing_guide/end_to_end/beginners_guide.md
index 0586f4708eb..a20eaff1f1c 100644
--- a/doc/development/testing_guide/end_to_end/beginners_guide.md
+++ b/doc/development/testing_guide/end_to_end/beginners_guide.md
@@ -349,3 +349,9 @@ Where `<test_file>` is:
- `qa/specs/features/browser_ui/1_manage/login/login_spec.rb` when running the Login example.
- `qa/specs/features/browser_ui/2_plan/issues/issue_spec.rb` when running the Issue example.
+
+## End-to-end test merge request template
+
+When submitting a new end-to-end test, use the ["New End to End Test"](https://gitlab.com/gitlab-org/gitlab/-/blob/master/.gitlab/merge_request_templates/New%20End%20To%20End%20Test.md)
+merge request description template for additional
+steps that are required prior a successful merge.
diff --git a/doc/development/what_requires_downtime.md b/doc/development/what_requires_downtime.md
index f486c6f0ec3..63a7ea4dfbd 100644
--- a/doc/development/what_requires_downtime.md
+++ b/doc/development/what_requires_downtime.md
@@ -51,7 +51,7 @@ We require indication of when it is safe to remove the column ignore with:
- `remove_with`: set to a GitLab release typically two releases (M+2) after adding the
column ignore.
- `remove_after`: set to a date after which we consider it safe to remove the column
- ignore, typically last date of the development cycle of release M+2 - namely the release date.
+ ignore, typically after the M+1 release date, during the M+2 development cycle.
This information allows us to reason better about column ignores and makes sure we
don't remove column ignores too early for both regular releases and deployments to GitLab.com. For
diff --git a/doc/user/application_security/vulnerabilities/index.md b/doc/user/application_security/vulnerabilities/index.md
index 848cb11a3c6..705964dba66 100644
--- a/doc/user/application_security/vulnerabilities/index.md
+++ b/doc/user/application_security/vulnerabilities/index.md
@@ -47,9 +47,9 @@ and allows you to comment on a change.
You can create an issue for a vulnerability by selecting the **Create issue** button.
-This creates a [confidential issue](../../project/issues/confidential_issues.md) in the
-project the vulnerability came from and pre-populates it with useful information from
-the vulnerability report. After the issue is created, GitLab redirects you to the
+This allows the user to create a [confidential issue](../../project/issues/confidential_issues.md)
+in the project the vulnerability came from. Fields are pre-populated with pertinent information
+from the vulnerability report. After the issue is created, GitLab redirects you to the
issue page so you can edit, assign, or comment on the issue.
## Link issues to the vulnerability
diff --git a/doc/user/group/saml_sso/index.md b/doc/user/group/saml_sso/index.md
index b934d59e06f..31ff86adf38 100644
--- a/doc/user/group/saml_sso/index.md
+++ b/doc/user/group/saml_sso/index.md
@@ -240,6 +240,10 @@ Users can unlink SAML for a group from their profile page. This can be helpful i
- You no longer want a group to be able to sign you in to GitLab.com.
- Your SAML NameID has changed and so GitLab can no longer find your user.
+CAUTION: **Warning:**
+Unlinking an account removes all roles assigned to that user within the group.
+If a user relinks their account, roles need to be reassigned.
+
For example, to unlink the `MyOrg` account, the following **Disconnect** button is available under **Profile > Accounts**:
![Unlink Group SAML](img/unlink_group_saml.png)
@@ -280,10 +284,6 @@ the user gets the highest access level from the groups. For example, if one grou
is linked as `Guest` and another `Maintainer`, a user in both groups gets `Maintainer`
access.
-CAUTION: **Warning:**
-Unlinking an account removes all roles assigned to that user within the group.
-If a user relinks their account, roles need to be reassigned.
-
## Glossary
| Term | Description |
diff --git a/doc/user/permissions.md b/doc/user/permissions.md
index 0cd1d3066fc..ca9b21370b0 100644
--- a/doc/user/permissions.md
+++ b/doc/user/permissions.md
@@ -159,6 +159,7 @@ The following table depicts the various user permission levels in a project.
| Manage Terraform state | | | | ✓ | ✓ |
| Manage license policy **(ULTIMATE)** | | | | ✓ | ✓ |
| Edit comments (posted by any user) | | | | ✓ | ✓ |
+| Reposition comments on images (posted by any user)|✓ (*11*) | ✓ (*11*) | ✓ (*11*) | ✓ | ✓ |
| Manage Error Tracking | | | | ✓ | ✓ |
| Delete wiki pages | | | | ✓ | ✓ |
| View project Audit Events | | | | ✓ | ✓ |
@@ -188,6 +189,7 @@ The following table depicts the various user permission levels in a project.
1. For information on eligible approvers for merge requests, see
[Eligible approvers](project/merge_requests/merge_request_approvals.md#eligible-approvers).
1. Owner permission is only available at the group or personal namespace level (and for instance admins) and is inherited by its projects.
+1. Applies only to comments on [Design Management](project/issues/design_management.md) designs.
## Project features permissions
@@ -405,6 +407,11 @@ automatically have access to projects and subgroups underneath. To support such
Users with minimal access can list the group in the UI and through the API. However, they cannot see
details such as projects or subgroups. They do not have access to the group's page or list any of its subgroups or projects.
+### Minimal access users take license seats
+
+Users with even a "minimal access" role are counted against your number of license seats. This
+requirement does not apply for [GitLab Gold/Ultimate](https://about.gitlab.com/pricing/) subscriptions.
+
## Project features
Project features like wiki and issues can be hidden from users depending on
diff --git a/doc/user/project/deploy_tokens/index.md b/doc/user/project/deploy_tokens/index.md
index f4309bf5de0..7dc29013f2b 100644
--- a/doc/user/project/deploy_tokens/index.md
+++ b/doc/user/project/deploy_tokens/index.md
@@ -149,6 +149,9 @@ belong either to the specific group or to one of its subgroups.
<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
For an overview, see [Group Deploy Tokens](https://youtu.be/8kxTJvaD9ks).
+The Group Deploy Tokens UI is now accessible under **Settings > Repository**,
+not **Settings > CI/CD** as indicated in the video.
+
To use a group deploy token:
1. [Create](#creating-a-deploy-token) a deploy token for a group.
diff --git a/doc/user/project/settings/index.md b/doc/user/project/settings/index.md
index add446c5eec..35d32ebfa35 100644
--- a/doc/user/project/settings/index.md
+++ b/doc/user/project/settings/index.md
@@ -198,8 +198,8 @@ To rename a repository:
1. Navigate to your project's **Settings > General**.
1. Under **Advanced**, click **Expand**.
-1. Under "Rename repository", change the "Path" to your liking.
-1. Hit **Rename project**.
+1. Under **Change path**, update the repository's path.
+1. Click **Change path**.
Remember that this can have unintended side effects since everyone with the
old URL won't be able to push or pull. Read more about what happens with the
diff --git a/lib/gitlab/experimentation.rb b/lib/gitlab/experimentation.rb
index 8ba7d212a61..f7b765592b8 100644
--- a/lib/gitlab/experimentation.rb
+++ b/lib/gitlab/experimentation.rb
@@ -87,23 +87,49 @@ module Gitlab
}.freeze
class << self
- def experiment(key)
- Gitlab::Experimentation::Experiment.new(key, **EXPERIMENTS[key])
+ def get_experiment(experiment_key)
+ return unless EXPERIMENTS.key?(experiment_key)
+
+ ::Gitlab::Experimentation::Experiment.new(experiment_key, **EXPERIMENTS[experiment_key])
end
- def enabled?(experiment_key)
- return false unless EXPERIMENTS.key?(experiment_key)
+ def active?(experiment_key)
+ experiment = get_experiment(experiment_key)
+ return false unless experiment
- experiment(experiment_key).enabled?
+ experiment.active?
end
- def enabled_for_attribute?(experiment_key, attribute)
- index = Digest::SHA1.hexdigest(attribute).hex % 100
- enabled_for_value?(experiment_key, index)
+ def in_experiment_group?(experiment_key, subject:)
+ return false if subject.blank?
+ return false unless active?(experiment_key)
+
+ experiment = get_experiment(experiment_key)
+ return false unless experiment
+
+ experiment.enabled_for_index?(index_for_subject(experiment, subject))
+ end
+
+ private
+
+ def index_for_subject(experiment, subject)
+ index = if experiment.use_backwards_compatible_subject_index
+ Digest::SHA1.hexdigest(subject_id(subject)).hex
+ else
+ Zlib.crc32("#{experiment.key}#{subject_id(subject)}")
+ end
+
+ index % 100
end
- def enabled_for_value?(experiment_key, value)
- enabled?(experiment_key) && experiment(experiment_key).enabled_for_index?(value)
+ def subject_id(subject)
+ if subject.respond_to?(:to_global_id)
+ subject.to_global_id.to_s
+ elsif subject.respond_to?(:to_s)
+ subject.to_s
+ else
+ raise ArgumentError.new('Subject must respond to `to_global_id` or `to_s`')
+ end
end
end
end
diff --git a/lib/gitlab/experimentation/controller_concern.rb b/lib/gitlab/experimentation/controller_concern.rb
index 6b481f4c87d..3b9b19d5606 100644
--- a/lib/gitlab/experimentation/controller_concern.rb
+++ b/lib/gitlab/experimentation/controller_concern.rb
@@ -3,7 +3,7 @@
require 'zlib'
# Controller concern that checks if an `experimentation_subject_id cookie` is present and sets it if absent.
-# Used for A/B testing of experimental features. Exposes the `experiment_enabled?(experiment_name)` method
+# Used for A/B testing of experimental features. Exposes the `experiment_enabled?(experiment_name, subject: nil)` method
# to controllers and views. It returns true when the experiment is enabled and the user is selected as part
# of the experimental group.
#
@@ -28,55 +28,56 @@ module Gitlab
}
end
- def push_frontend_experiment(experiment_key)
+ def push_frontend_experiment(experiment_key, subject: nil)
var_name = experiment_key.to_s.camelize(:lower)
- enabled = experiment_enabled?(experiment_key)
+
+ enabled = experiment_enabled?(experiment_key, subject: subject)
gon.push({ experiments: { var_name => enabled } }, true)
end
- def experiment_enabled?(experiment_key)
+ def experiment_enabled?(experiment_key, subject: nil)
+ return true if forced_enabled?(experiment_key)
return false if dnt_enabled?
- return true if Experimentation.enabled_for_value?(experiment_key, experimentation_subject_index(experiment_key))
- return true if forced_enabled?(experiment_key)
+ subject ||= fallback_experimentation_subject_index(experiment_key)
- false
+ Experimentation.in_experiment_group?(experiment_key, subject: subject)
end
- def track_experiment_event(experiment_key, action, value = nil)
+ def track_experiment_event(experiment_key, action, value = nil, subject: nil)
return if dnt_enabled?
- track_experiment_event_for(experiment_key, action, value) do |tracking_data|
+ track_experiment_event_for(experiment_key, action, value, subject: subject) do |tracking_data|
::Gitlab::Tracking.event(tracking_data.delete(:category), tracking_data.delete(:action), **tracking_data)
end
end
- def frontend_experimentation_tracking_data(experiment_key, action, value = nil)
+ def frontend_experimentation_tracking_data(experiment_key, action, value = nil, subject: nil)
return if dnt_enabled?
- track_experiment_event_for(experiment_key, action, value) do |tracking_data|
+ track_experiment_event_for(experiment_key, action, value, subject: subject) do |tracking_data|
gon.push(tracking_data: tracking_data)
end
end
def record_experiment_user(experiment_key)
return if dnt_enabled?
- return unless Experimentation.enabled?(experiment_key) && current_user
+ return unless Experimentation.active?(experiment_key) && current_user
- ::Experiment.add_user(experiment_key, tracking_group(experiment_key), current_user)
+ ::Experiment.add_user(experiment_key, tracking_group(experiment_key, nil, subject: current_user), current_user)
end
def record_experiment_conversion_event(experiment_key)
return if dnt_enabled?
return unless current_user
- return unless Experimentation.enabled?(experiment_key)
+ return unless Experimentation.active?(experiment_key)
::Experiment.record_conversion_event(experiment_key, current_user)
end
- def experiment_tracking_category_and_group(experiment_key)
- "#{tracking_category(experiment_key)}:#{tracking_group(experiment_key, '_group')}"
+ def experiment_tracking_category_and_group(experiment_key, subject: nil)
+ "#{tracking_category(experiment_key)}:#{tracking_group(experiment_key, '_group', subject: subject)}"
end
private
@@ -89,40 +90,41 @@ module Gitlab
cookies.signed[:experimentation_subject_id]
end
- def experimentation_subject_index(experiment_key)
+ def fallback_experimentation_subject_index(experiment_key)
return if experimentation_subject_id.blank?
- if Experimentation.experiment(experiment_key).use_backwards_compatible_subject_index
- experimentation_subject_id.delete('-').hex % 100
+ if Experimentation.get_experiment(experiment_key).use_backwards_compatible_subject_index
+ experimentation_subject_id.delete('-')
else
- Zlib.crc32("#{experiment_key}#{experimentation_subject_id}") % 100
+ experimentation_subject_id
end
end
- def track_experiment_event_for(experiment_key, action, value)
- return unless Experimentation.enabled?(experiment_key)
+ def track_experiment_event_for(experiment_key, action, value, subject: nil)
+ return unless Experimentation.active?(experiment_key)
- yield experimentation_tracking_data(experiment_key, action, value)
+ yield experimentation_tracking_data(experiment_key, action, value, subject: subject)
end
- def experimentation_tracking_data(experiment_key, action, value)
+ def experimentation_tracking_data(experiment_key, action, value, subject: nil)
{
category: tracking_category(experiment_key),
action: action,
- property: tracking_group(experiment_key, "_group"),
- label: experimentation_subject_id,
+ property: tracking_group(experiment_key, "_group", subject: subject),
+ label: tracking_label(subject),
value: value
}.compact
end
def tracking_category(experiment_key)
- Experimentation.experiment(experiment_key).tracking_category
+ Experimentation.get_experiment(experiment_key).tracking_category
end
- def tracking_group(experiment_key, suffix = nil)
- return unless Experimentation.enabled?(experiment_key)
+ def tracking_group(experiment_key, suffix = nil, subject: nil)
+ return unless Experimentation.active?(experiment_key)
- group = experiment_enabled?(experiment_key) ? GROUP_EXPERIMENTAL : GROUP_CONTROL
+ subject ||= fallback_experimentation_subject_index(experiment_key)
+ group = experiment_enabled?(experiment_key, subject: subject) ? GROUP_EXPERIMENTAL : GROUP_CONTROL
suffix ? "#{group}#{suffix}" : group
end
@@ -130,6 +132,16 @@ module Gitlab
def forced_enabled?(experiment_key)
params.has_key?(:force_experiment) && params[:force_experiment] == experiment_key.to_s
end
+
+ def tracking_label(subject)
+ return experimentation_subject_id if subject.blank?
+
+ if subject.respond_to?(:to_global_id)
+ Digest::MD5.hexdigest(subject.to_global_id.to_s)
+ else
+ Digest::MD5.hexdigest(subject.to_s)
+ end
+ end
end
end
end
diff --git a/lib/gitlab/experimentation/experiment.rb b/lib/gitlab/experimentation/experiment.rb
index bacbaa6949c..e594c3bedeb 100644
--- a/lib/gitlab/experimentation/experiment.rb
+++ b/lib/gitlab/experimentation/experiment.rb
@@ -3,16 +3,17 @@
module Gitlab
module Experimentation
class Experiment
- attr_reader :tracking_category, :use_backwards_compatible_subject_index
+ attr_reader :key, :tracking_category, :use_backwards_compatible_subject_index
def initialize(key, **params)
+ @key = key
@tracking_category = params[:tracking_category]
@use_backwards_compatible_subject_index = params[:use_backwards_compatible_subject_index]
@experiment_percentage = Feature.get(:"#{key}_experiment_percentage").percentage_of_time_value # rubocop:disable Gitlab/AvoidFeatureGet
end
- def enabled?
+ def active?
::Gitlab.dev_env_or_com? && experiment_percentage > 0
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 5995cc51405..1965992033d 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -2119,6 +2119,9 @@ msgstr ""
msgid "AdminUsers|Blocking user has the following effects:"
msgstr ""
+msgid "AdminUsers|Cannot sign in or access instance information"
+msgstr ""
+
msgid "AdminUsers|Cannot unblock LDAP blocked users"
msgstr ""
@@ -2155,6 +2158,9 @@ msgstr ""
msgid "AdminUsers|External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets."
msgstr ""
+msgid "AdminUsers|For more information, please refer to the %{link_start}user account deletion documentation.%{link_end}"
+msgstr ""
+
msgid "AdminUsers|Is using seat"
msgstr ""
@@ -2191,6 +2197,15 @@ msgstr ""
msgid "AdminUsers|Regular users have access to their groups and projects"
msgstr ""
+msgid "AdminUsers|Reject"
+msgstr ""
+
+msgid "AdminUsers|Reject request"
+msgstr ""
+
+msgid "AdminUsers|Rejected users:"
+msgstr ""
+
msgid "AdminUsers|Restore user access to the account, including web, Git and API."
msgstr ""
@@ -2248,6 +2263,9 @@ msgstr ""
msgid "AdminUsers|When the user logs back in, their account will reactivate as a fully active account"
msgstr ""
+msgid "AdminUsers|Will be deleted"
+msgstr ""
+
msgid "AdminUsers|Without projects"
msgstr ""
@@ -12773,6 +12791,9 @@ msgstr ""
msgid "GitLab Workhorse"
msgstr ""
+msgid "GitLab account request rejected"
+msgstr ""
+
msgid "GitLab commit"
msgstr ""
@@ -13811,6 +13832,9 @@ msgstr ""
msgid "HealthCheck|Unhealthy"
msgstr ""
+msgid "Hello %{name},"
+msgstr ""
+
msgid "Hello there"
msgstr ""
@@ -14790,6 +14814,9 @@ msgstr ""
msgid "Integrations|Standard"
msgstr ""
+msgid "Integrations|This integration, and inheriting projects were reset."
+msgstr ""
+
msgid "Integrations|To keep this project going, create a new issue."
msgstr ""
@@ -20317,6 +20344,9 @@ msgstr ""
msgid "Please complete your profile with email address"
msgstr ""
+msgid "Please contact your GitLab administrator if you think this is an error."
+msgstr ""
+
msgid "Please contact your administrator with any questions."
msgstr ""
@@ -28110,6 +28140,9 @@ msgstr ""
msgid "This user cannot be unlocked manually from GitLab"
msgstr ""
+msgid "This user does not have a pending request"
+msgstr ""
+
msgid "This user has no active %{type}."
msgstr ""
@@ -31045,6 +31078,9 @@ msgstr ""
msgid "You are not allowed to push into this branch. Create another branch or open a merge request."
msgstr ""
+msgid "You are not allowed to reject a user"
+msgstr ""
+
msgid "You are not allowed to unlink your primary login account"
msgstr ""
@@ -31513,6 +31549,9 @@ msgstr ""
msgid "You've already enabled two-factor authentication using one time password authenticators. In order to register a different device, you must first disable two-factor authentication."
msgstr ""
+msgid "You've rejected %{user}"
+msgstr ""
+
msgid "YouTube"
msgstr ""
@@ -31747,6 +31786,9 @@ msgstr ""
msgid "Your request for access has been queued for review."
msgstr ""
+msgid "Your request to join %{host} has been rejected."
+msgstr ""
+
msgid "Your requirements are being imported. Once finished, you'll receive a confirmation email."
msgstr ""
diff --git a/spec/controllers/admin/integrations_controller_spec.rb b/spec/controllers/admin/integrations_controller_spec.rb
index 1a13d016b73..1b4c48d93c7 100644
--- a/spec/controllers/admin/integrations_controller_spec.rb
+++ b/spec/controllers/admin/integrations_controller_spec.rb
@@ -73,4 +73,20 @@ RSpec.describe Admin::IntegrationsController do
end
end
end
+
+ describe '#reset' do
+ let(:integration) { create(:jira_service, :instance) }
+
+ before do
+ post :reset, params: { id: integration.class.to_param }
+ end
+
+ it 'returns 200 OK' do
+ expected_json = {}.to_json
+
+ expect(flash[:notice]).to eq('This integration, and inheriting projects were reset.')
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.body).to eq(expected_json)
+ end
+ end
end
diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb
index d0d1fa6a6bc..f902a3d2541 100644
--- a/spec/controllers/admin/users_controller_spec.rb
+++ b/spec/controllers/admin/users_controller_spec.rb
@@ -102,6 +102,57 @@ RSpec.describe Admin::UsersController do
end
end
+ describe 'DELETE #reject' do
+ subject { put :reject, params: { id: user.username } }
+
+ context 'when rejecting a pending user' do
+ let(:user) { create(:user, :blocked_pending_approval) }
+
+ it 'hard deletes the user', :sidekiq_inline do
+ subject
+
+ expect(User.exists?(user.id)).to be_falsy
+ end
+
+ it 'displays the rejection message' do
+ subject
+
+ expect(response).to redirect_to(admin_users_path)
+ expect(flash[:notice]).to eq("You've rejected #{user.name}")
+ end
+
+ it 'sends the user a rejection email' do
+ expect_next_instance_of(NotificationService) do |notification|
+ allow(notification).to receive(:user_admin_rejection).with(user.name, user.notification_email)
+ end
+
+ subject
+ end
+ end
+
+ context 'when user is not pending' do
+ let(:user) { create(:user, state: 'active') }
+
+ it 'does not reject and delete the user' do
+ subject
+
+ expect(User.exists?(user.id)).to be_truthy
+ end
+
+ it 'displays the error' do
+ subject
+
+ expect(flash[:alert]).to eq('This user does not have a pending request')
+ end
+
+ it 'does not email the user' do
+ expect(NotificationService).not_to receive(:new)
+
+ subject
+ end
+ end
+ end
+
describe 'PUT #approve' do
let(:user) { create(:user, :blocked_pending_approval) }
diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb
index 90b38799629..939c36a98b2 100644
--- a/spec/controllers/groups_controller_spec.rb
+++ b/spec/controllers/groups_controller_spec.rb
@@ -333,7 +333,7 @@ RSpec.describe GroupsController, factory_default: :keep do
context 'and the user is part of the control group' do
before do
- stub_experiment_for_user(onboarding_issues: false)
+ stub_experiment_for_subject(onboarding_issues: false)
end
it 'tracks the event with the "created_namespace" action with the "control_group" property', :snowplow do
@@ -350,7 +350,7 @@ RSpec.describe GroupsController, factory_default: :keep do
context 'and the user is part of the experimental group' do
before do
- stub_experiment_for_user(onboarding_issues: true)
+ stub_experiment_for_subject(onboarding_issues: true)
end
it 'tracks the event with the "created_namespace" action with the "experimental_group" property', :snowplow do
diff --git a/spec/controllers/invites_controller_spec.rb b/spec/controllers/invites_controller_spec.rb
index 2d13b942c31..8aaed97ad04 100644
--- a/spec/controllers/invites_controller_spec.rb
+++ b/spec/controllers/invites_controller_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe InvitesController, :snowplow do
shared_examples "tracks the 'accepted' event for the invitation reminders experiment" do
before do
stub_experiment(invitation_reminders: true)
- allow(Gitlab::Experimentation).to receive(:enabled_for_attribute?).with(:invitation_reminders, member.invite_email).and_return(experimental_group)
+ stub_experiment_for_subject(invitation_reminders: experimental_group)
end
context 'when in the control group' do
diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb
index f9ad3a33fd1..bc6d2ec2ed1 100644
--- a/spec/controllers/projects/jobs_controller_spec.rb
+++ b/spec/controllers/projects/jobs_controller_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state do
describe 'pushing tracking_data to Gon' do
before do
stub_experiment(jobs_empty_state: experiment_active)
- stub_experiment_for_user(jobs_empty_state: in_experiment_group)
+ stub_experiment_for_subject(jobs_empty_state: in_experiment_group)
get_index
end
diff --git a/spec/controllers/registrations/experience_levels_controller_spec.rb b/spec/controllers/registrations/experience_levels_controller_spec.rb
index 4be67f29107..015daba8682 100644
--- a/spec/controllers/registrations/experience_levels_controller_spec.rb
+++ b/spec/controllers/registrations/experience_levels_controller_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe Registrations::ExperienceLevelsController do
context 'with an authenticated user' do
before do
sign_in(user)
- stub_experiment_for_user(onboarding_issues: true)
+ stub_experiment_for_subject(onboarding_issues: true)
end
it { is_expected.to have_gitlab_http_status(:ok) }
@@ -28,7 +28,7 @@ RSpec.describe Registrations::ExperienceLevelsController do
context 'when not part of the onboarding issues experiment' do
before do
- stub_experiment_for_user(onboarding_issues: false)
+ stub_experiment_for_subject(onboarding_issues: false)
end
it { is_expected.to have_gitlab_http_status(:not_found) }
@@ -47,12 +47,12 @@ RSpec.describe Registrations::ExperienceLevelsController do
context 'with an authenticated user' do
before do
sign_in(user)
- stub_experiment_for_user(onboarding_issues: true)
+ stub_experiment_for_subject(onboarding_issues: true)
end
context 'when not part of the onboarding issues experiment' do
before do
- stub_experiment_for_user(onboarding_issues: false)
+ stub_experiment_for_subject(onboarding_issues: false)
end
it { is_expected.to have_gitlab_http_status(:not_found) }
@@ -90,7 +90,7 @@ RSpec.describe Registrations::ExperienceLevelsController do
let(:issues_board) { build(:board, id: 123, project: project) }
before do
- stub_experiment_for_user(
+ stub_experiment_for_subject(
onboarding_issues: true,
default_to_issues_board: default_to_issues_board_xp?
)
diff --git a/spec/controllers/root_controller_spec.rb b/spec/controllers/root_controller_spec.rb
index 1db99a09404..85f9ea66c5f 100644
--- a/spec/controllers/root_controller_spec.rb
+++ b/spec/controllers/root_controller_spec.rb
@@ -125,7 +125,7 @@ RSpec.describe RootController do
context 'when experiment is enabled' do
before do
- stub_experiment_for_user(customize_homepage: true)
+ stub_experiment_for_subject(customize_homepage: true)
end
it 'renders the default dashboard' do
diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb
index 998d93420d1..8be7607208b 100644
--- a/spec/factories/merge_requests.rb
+++ b/spec/factories/merge_requests.rb
@@ -52,7 +52,7 @@ FactoryBot.define do
after(:build) do |merge_request, evaluator|
metrics = merge_request.build_metrics
- metrics.merged_at = 1.week.ago
+ metrics.merged_at = 1.week.from_now
metrics.merged_by = evaluator.merged_by
metrics.pipeline = create(:ci_empty_pipeline)
end
diff --git a/spec/features/admin/users/user_spec.rb b/spec/features/admin/users/user_spec.rb
index cbf5c8db169..46380218e91 100644
--- a/spec/features/admin/users/user_spec.rb
+++ b/spec/features/admin/users/user_spec.rb
@@ -37,9 +37,7 @@ RSpec.describe 'Admin::Users::User' do
expect(page).to have_content(user.name)
expect(page).to have_content('Pending approval')
expect(page).to have_link('Approve user')
- expect(page).to have_button('Block user')
- expect(page).to have_button('Delete user')
- expect(page).to have_button('Delete user and contributions')
+ expect(page).to have_link('Reject request')
end
end
diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb
index 9b6d7df3709..4edda9febbe 100644
--- a/spec/features/projects/jobs_spec.rb
+++ b/spec/features/projects/jobs_spec.rb
@@ -28,7 +28,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
context 'with no jobs' do
before do
stub_experiment(jobs_empty_state: experiment_active)
- stub_experiment_for_user(jobs_empty_state: in_experiment_group)
+ stub_experiment_for_subject(jobs_empty_state: in_experiment_group)
visit project_jobs_path(project)
end
diff --git a/spec/features/registrations/experience_level_spec.rb b/spec/features/registrations/experience_level_spec.rb
index 30f19870f69..25496e2fef1 100644
--- a/spec/features/registrations/experience_level_spec.rb
+++ b/spec/features/registrations/experience_level_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe 'Experience level screen' do
before do
group.add_owner(user)
gitlab_sign_in(user)
- stub_experiment_for_user(onboarding_issues: true)
+ stub_experiment_for_subject(onboarding_issues: true)
visit users_sign_up_experience_level_path(namespace_path: group.to_param)
end
diff --git a/spec/frontend/integrations/edit/store/actions_spec.js b/spec/frontend/integrations/edit/store/actions_spec.js
index 5b5c8d6f76e..1ff881c265d 100644
--- a/spec/frontend/integrations/edit/store/actions_spec.js
+++ b/spec/frontend/integrations/edit/store/actions_spec.js
@@ -1,13 +1,19 @@
import testAction from 'helpers/vuex_action_helper';
+import { refreshCurrentPage } from '~/lib/utils/url_utility';
import createState from '~/integrations/edit/store/state';
import {
setOverride,
setIsSaving,
setIsTesting,
setIsResetting,
+ requestResetIntegration,
+ receiveResetIntegrationSuccess,
+ receiveResetIntegrationError,
} from '~/integrations/edit/store/actions';
import * as types from '~/integrations/edit/store/mutation_types';
+jest.mock('~/lib/utils/url_utility');
+
describe('Integration form store actions', () => {
let state;
@@ -40,4 +46,28 @@ describe('Integration form store actions', () => {
]);
});
});
+
+ describe('requestResetIntegration', () => {
+ it('should commit REQUEST_RESET_INTEGRATION mutation', () => {
+ return testAction(requestResetIntegration, null, state, [
+ { type: types.REQUEST_RESET_INTEGRATION },
+ ]);
+ });
+ });
+
+ describe('receiveResetIntegrationSuccess', () => {
+ it('should call refreshCurrentPage()', () => {
+ return testAction(receiveResetIntegrationSuccess, null, state, [], [], () => {
+ expect(refreshCurrentPage).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('receiveResetIntegrationError', () => {
+ it('should commit RECEIVE_RESET_INTEGRATION_ERROR mutation', () => {
+ return testAction(receiveResetIntegrationError, null, state, [
+ { type: types.RECEIVE_RESET_INTEGRATION_ERROR },
+ ]);
+ });
+ });
});
diff --git a/spec/frontend/integrations/edit/store/mutations_spec.js b/spec/frontend/integrations/edit/store/mutations_spec.js
index 4707b4b3714..81f39adb87f 100644
--- a/spec/frontend/integrations/edit/store/mutations_spec.js
+++ b/spec/frontend/integrations/edit/store/mutations_spec.js
@@ -40,4 +40,20 @@ describe('Integration form store mutations', () => {
expect(state.isResetting).toBe(true);
});
});
+
+ describe(`${types.REQUEST_RESET_INTEGRATION}`, () => {
+ it('sets isResetting', () => {
+ mutations[types.REQUEST_RESET_INTEGRATION](state);
+
+ expect(state.isResetting).toBe(true);
+ });
+ });
+
+ describe(`${types.RECEIVE_RESET_INTEGRATION_ERROR}`, () => {
+ it('sets isResetting', () => {
+ mutations[types.RECEIVE_RESET_INTEGRATION_ERROR](state);
+
+ expect(state.isResetting).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/pipelines/unwrapping_utils_spec.js b/spec/frontend/pipelines/unwrapping_utils_spec.js
new file mode 100644
index 00000000000..34413ad3ef3
--- /dev/null
+++ b/spec/frontend/pipelines/unwrapping_utils_spec.js
@@ -0,0 +1,127 @@
+import {
+ unwrapGroups,
+ unwrapNodesWithName,
+ unwrapStagesWithNeeds,
+} from '~/pipelines/components/unwrapping_utils';
+
+const groupsArray = [
+ {
+ name: 'build_a',
+ size: 1,
+ status: {
+ label: 'passed',
+ group: 'success',
+ icon: 'status_success',
+ },
+ },
+ {
+ name: 'bob_the_build',
+ size: 1,
+ status: {
+ label: 'passed',
+ group: 'success',
+ icon: 'status_success',
+ },
+ },
+];
+
+const basicStageInfo = {
+ name: 'center_stage',
+ status: {
+ action: null,
+ },
+};
+
+const stagesAndGroups = [
+ {
+ ...basicStageInfo,
+ groups: {
+ nodes: groupsArray,
+ },
+ },
+];
+
+const needArray = [
+ {
+ name: 'build_b',
+ },
+];
+
+const elephantArray = [
+ {
+ name: 'build_b',
+ elephant: 'gray',
+ },
+];
+
+const baseJobs = {
+ name: 'test_d',
+ status: {
+ icon: 'status_success',
+ tooltip: null,
+ hasDetails: true,
+ detailsPath: '/root/abcd-dag/-/pipelines/162',
+ group: 'success',
+ action: null,
+ },
+};
+
+const jobArrayWithNeeds = [
+ {
+ ...baseJobs,
+ needs: {
+ nodes: needArray,
+ },
+ },
+];
+
+const jobArrayWithElephant = [
+ {
+ ...baseJobs,
+ needs: {
+ nodes: elephantArray,
+ },
+ },
+];
+
+const completeMock = [
+ {
+ ...basicStageInfo,
+ groups: {
+ nodes: groupsArray.map(group => ({ ...group, jobs: { nodes: jobArrayWithNeeds } })),
+ },
+ },
+];
+
+describe('Shared pipeline unwrapping utils', () => {
+ describe('unwrapGroups', () => {
+ it('takes stages without nodes and returns the unwrapped groups', () => {
+ expect(unwrapGroups(stagesAndGroups)[0].groups).toEqual(groupsArray);
+ });
+
+ it('keeps other stage properties intact', () => {
+ expect(unwrapGroups(stagesAndGroups)[0]).toMatchObject(basicStageInfo);
+ });
+ });
+
+ describe('unwrapNodesWithName', () => {
+ it('works with no field argument', () => {
+ expect(unwrapNodesWithName(jobArrayWithNeeds, 'needs')[0].needs).toEqual([needArray[0].name]);
+ });
+
+ it('works with custom field argument', () => {
+ expect(unwrapNodesWithName(jobArrayWithElephant, 'needs', 'elephant')[0].needs).toEqual([
+ elephantArray[0].elephant,
+ ]);
+ });
+ });
+
+ describe('unwrapStagesWithNeeds', () => {
+ it('removes nodes from groups, jobs, and needs', () => {
+ const firstProcessedGroup = unwrapStagesWithNeeds(completeMock)[0].groups[0];
+ expect(firstProcessedGroup).toMatchObject(groupsArray[0]);
+ expect(firstProcessedGroup.jobs[0]).toMatchObject(baseJobs);
+ expect(firstProcessedGroup.jobs[0].needs[0]).toBe(needArray[0].name);
+ });
+ });
+});
diff --git a/spec/graphql/types/countable_connection_type_spec.rb b/spec/graphql/types/countable_connection_type_spec.rb
index 3b3c02baa5d..648dbacff42 100644
--- a/spec/graphql/types/countable_connection_type_spec.rb
+++ b/spec/graphql/types/countable_connection_type_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GitlabSchema.types['MergeRequestConnection'] do
+RSpec.describe GitlabSchema.types['PipelineConnection'] do
it 'has the expected fields' do
expected_fields = %i[count page_info edges nodes]
diff --git a/spec/graphql/types/merge_request_connection_type_spec.rb b/spec/graphql/types/merge_request_connection_type_spec.rb
new file mode 100644
index 00000000000..f4ab6c79721
--- /dev/null
+++ b/spec/graphql/types/merge_request_connection_type_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['MergeRequestConnection'] do
+ it 'has the expected fields' do
+ expected_fields = %i[count totalTimeToMerge page_info edges nodes]
+
+ expect(described_class).to have_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/helpers/services_helper_spec.rb b/spec/helpers/services_helper_spec.rb
index d6b48b3d565..29c83b7c4da 100644
--- a/spec/helpers/services_helper_spec.rb
+++ b/spec/helpers/services_helper_spec.rb
@@ -59,4 +59,57 @@ RSpec.describe ServicesHelper do
end
end
end
+
+ describe '#scoped_reset_integration_path' do
+ let(:integration) { build_stubbed(:jira_service) }
+ let(:group) { nil }
+
+ subject { helper.scoped_reset_integration_path(integration, group: group) }
+
+ context 'when no group is present' do
+ it 'returns instance-level path' do
+ is_expected.to eq(reset_admin_application_settings_integration_path(integration))
+ end
+ end
+
+ context 'when group is present' do
+ let(:group) { build_stubbed(:group) }
+
+ it 'returns group-level path' do
+ is_expected.to eq(reset_group_settings_integration_path(group, integration))
+ end
+ end
+ end
+
+ describe '#reset_integrations?' do
+ let(:group) { nil }
+
+ subject { helper.reset_integrations?(group: group) }
+
+ context 'when `reset_integrations` is not enabled' do
+ it 'returns false' do
+ stub_feature_flags(reset_integrations: false)
+
+ is_expected.to eq(false)
+ end
+ end
+
+ context 'when `reset_integrations` is enabled' do
+ it 'returns true' do
+ stub_feature_flags(reset_integrations: true)
+
+ is_expected.to eq(true)
+ end
+ end
+
+ context 'when `reset_integrations` is enabled for a group' do
+ let(:group) { build_stubbed(:group) }
+
+ it 'returns true' do
+ stub_feature_flags(reset_integrations: group)
+
+ is_expected.to eq(true)
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/experimentation/controller_concern_spec.rb b/spec/lib/gitlab/experimentation/controller_concern_spec.rb
index 727afdd4063..3cda5d230c5 100644
--- a/spec/lib/gitlab/experimentation/controller_concern_spec.rb
+++ b/spec/lib/gitlab/experimentation/controller_concern_spec.rb
@@ -75,29 +75,24 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
describe '#push_frontend_experiment' do
it 'pushes an experiment to the frontend' do
gon = instance_double('gon')
- experiments = { experiments: { 'myExperiment' => true } }
-
- stub_experiment_for_user(my_experiment: true)
+ stub_experiment_for_subject(my_experiment: true)
allow(controller).to receive(:gon).and_return(gon)
- expect(gon).to receive(:push).with(experiments, true)
+ expect(gon).to receive(:push).with({ experiments: { 'myExperiment' => true } }, true)
controller.push_frontend_experiment(:my_experiment)
end
end
describe '#experiment_enabled?' do
- def check_experiment(exp_key = :test_experiment)
- controller.experiment_enabled?(exp_key)
+ def check_experiment(exp_key = :test_experiment, subject = nil)
+ controller.experiment_enabled?(exp_key, subject: subject)
end
subject { check_experiment }
context 'cookie is not present' do
- it 'calls Gitlab::Experimentation.enabled_for_value? with the name of the experiment and an experimentation_subject_index of nil' do
- expect(Gitlab::Experimentation).to receive(:enabled_for_value?).with(:test_experiment, nil)
- check_experiment
- end
+ it { is_expected.to eq(false) }
end
context 'cookie is present' do
@@ -109,37 +104,56 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
end
where(:experiment_key, :index_value) do
- :test_experiment | 40 # Zlib.crc32('test_experimentabcd-1234') % 100 = 40
- :backwards_compatible_test_experiment | 76 # 'abcd1234'.hex % 100 = 76
+ :test_experiment | 'abcd-1234'
+ :backwards_compatible_test_experiment | 'abcd1234'
end
with_them do
- it 'calls Gitlab::Experimentation.enabled_for_value? with the name of the experiment and the calculated experimentation_subject_index based on the uuid' do
- expect(Gitlab::Experimentation).to receive(:enabled_for_value?).with(experiment_key, index_value)
+ it 'calls Gitlab::Experimentation.in_experiment_group?? with the name of the experiment and the calculated experimentation_subject_index based on the uuid' do
+ expect(Gitlab::Experimentation).to receive(:in_experiment_group?).with(experiment_key, subject: index_value)
+
check_experiment(experiment_key)
end
end
- end
- it 'returns true when DNT: 0 is set in the request' do
- allow(Gitlab::Experimentation).to receive(:enabled_for_value?) { true }
- controller.request.headers['DNT'] = '0'
+ context 'when subject is given' do
+ let(:user) { build(:user) }
- is_expected.to be_truthy
+ it 'uses the subject' do
+ expect(Gitlab::Experimentation).to receive(:in_experiment_group?).with(:test_experiment, subject: user)
+
+ check_experiment(:test_experiment, user)
+ end
+ end
end
- it 'returns false when DNT: 1 is set in the request' do
- allow(Gitlab::Experimentation).to receive(:enabled_for_value?) { true }
- controller.request.headers['DNT'] = '1'
+ context 'do not track' do
+ before do
+ allow(Gitlab::Experimentation).to receive(:in_experiment_group?) { true }
+ end
- is_expected.to be_falsy
+ context 'when do not track is disabled' do
+ before do
+ controller.request.headers['DNT'] = '0'
+ end
+
+ it { is_expected.to eq(true) }
+ end
+
+ context 'when do not track is enabled' do
+ before do
+ controller.request.headers['DNT'] = '1'
+ end
+
+ it { is_expected.to eq(false) }
+ end
end
- describe 'URL parameter to force enable experiment' do
+ context 'URL parameter to force enable experiment' do
it 'returns true unconditionally' do
get :index, params: { force_experiment: :test_experiment }
- is_expected.to be_truthy
+ is_expected.to eq(true)
end
end
end
@@ -152,7 +166,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
context 'the user is part of the experimental group' do
before do
- stub_experiment_for_user(test_experiment: true)
+ stub_experiment_for_subject(test_experiment: true)
end
it 'tracks the event with the right parameters' do
@@ -169,7 +183,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
context 'the user is part of the control group' do
before do
- stub_experiment_for_user(test_experiment: false)
+ stub_experiment_for_subject(test_experiment: false)
end
it 'tracks the event with the right parameters' do
@@ -212,6 +226,59 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
expect_no_snowplow_event
end
end
+
+ context 'subject is provided' do
+ before do
+ stub_experiment_for_subject(test_experiment: false)
+ end
+
+ it "provides the subject's hashed global_id as label" do
+ experiment_subject = double(:subject, to_global_id: 'abc')
+
+ controller.track_experiment_event(:test_experiment, 'start', 1, subject: experiment_subject)
+
+ expect_snowplow_event(
+ category: 'Team',
+ action: 'start',
+ property: 'control_group',
+ value: 1,
+ label: Digest::MD5.hexdigest('abc')
+ )
+ end
+
+ it "provides the subject's hashed string representation as label" do
+ experiment_subject = 'somestring'
+
+ controller.track_experiment_event(:test_experiment, 'start', 1, subject: experiment_subject)
+
+ expect_snowplow_event(
+ category: 'Team',
+ action: 'start',
+ property: 'control_group',
+ value: 1,
+ label: Digest::MD5.hexdigest('somestring')
+ )
+ end
+ end
+
+ context 'no subject is provided but cookie is set' do
+ before do
+ get :index
+ stub_experiment_for_subject(test_experiment: false)
+ end
+
+ it 'uses the experimentation_subject_id as fallback' do
+ controller.track_experiment_event(:test_experiment, 'start', 1)
+
+ expect_snowplow_event(
+ category: 'Team',
+ action: 'start',
+ property: 'control_group',
+ value: 1,
+ label: cookies.permanent.signed[:experimentation_subject_id]
+ )
+ end
+ end
end
context 'when the experiment is disabled' do
@@ -235,7 +302,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
context 'the user is part of the experimental group' do
before do
- stub_experiment_for_user(test_experiment: true)
+ stub_experiment_for_subject(test_experiment: true)
end
it 'pushes the right parameters to gon' do
@@ -253,9 +320,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
context 'the user is part of the control group' do
before do
- allow_next_instance_of(described_class) do |instance|
- allow(instance).to receive(:experiment_enabled?).with(:test_experiment).and_return(false)
- end
+ stub_experiment_for_subject(test_experiment: false)
end
it 'pushes the right parameters to gon' do
@@ -308,7 +373,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
it 'does not push data to gon' do
controller.frontend_experimentation_tracking_data(:test_experiment, 'start')
- expect(Gon.method_defined?(:tracking_data)).to be_falsey
+ expect(Gon.method_defined?(:tracking_data)).to eq(false)
end
end
end
@@ -319,7 +384,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
end
it 'does not push data to gon' do
- expect(Gon.method_defined?(:tracking_data)).to be_falsey
+ expect(Gon.method_defined?(:tracking_data)).to eq(false)
controller.track_experiment_event(:test_experiment, 'start')
end
end
@@ -336,7 +401,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
context 'the user is part of the experimental group' do
before do
- stub_experiment_for_user(test_experiment: true)
+ stub_experiment_for_subject(test_experiment: true)
end
it 'calls add_user on the Experiment model' do
@@ -348,9 +413,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
context 'the user is part of the control group' do
before do
- allow_next_instance_of(described_class) do |instance|
- allow(instance).to receive(:experiment_enabled?).with(:test_experiment).and_return(false)
- end
+ stub_experiment_for_subject(test_experiment: false)
end
it 'calls add_user on the Experiment model' do
@@ -395,6 +458,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
context 'is disabled' do
before do
request.headers['DNT'] = '0'
+ stub_experiment_for_subject(test_experiment: false)
end
it 'calls add_user on the Experiment model' do
@@ -475,7 +539,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
it 'returns a string with the experiment tracking category & group joined with a ":"' do
expect(controller).to receive(:tracking_category).with(experiment_key).and_return('Experiment::Category')
- expect(controller).to receive(:tracking_group).with(experiment_key, '_group').and_return('experimental_group')
+ expect(controller).to receive(:tracking_group).with(experiment_key, '_group', subject: nil).and_return('experimental_group')
expect(subject).to eq('Experiment::Category:experimental_group')
end
diff --git a/spec/lib/gitlab/experimentation/experiment_spec.rb b/spec/lib/gitlab/experimentation/experiment_spec.rb
index 4af76e9e920..7b1d1763010 100644
--- a/spec/lib/gitlab/experimentation/experiment_spec.rb
+++ b/spec/lib/gitlab/experimentation/experiment_spec.rb
@@ -20,14 +20,14 @@ RSpec.describe Gitlab::Experimentation::Experiment do
subject(:experiment) { described_class.new(:experiment_key, **params) }
- describe '#enabled?' do
+ describe '#active?' do
before do
allow(Gitlab).to receive(:dev_env_or_com?).and_return(on_gitlab_com)
end
- subject { experiment.enabled? }
+ subject { experiment.active? }
- where(:on_gitlab_com, :percentage, :is_enabled) do
+ where(:on_gitlab_com, :percentage, :is_active) do
true | 0 | false
true | 10 | true
false | 0 | false
@@ -35,7 +35,7 @@ RSpec.describe Gitlab::Experimentation::Experiment do
end
with_them do
- it { is_expected.to eq(is_enabled) }
+ it { is_expected.to eq(is_active) }
end
end
diff --git a/spec/lib/gitlab/experimentation_spec.rb b/spec/lib/gitlab/experimentation_spec.rb
index 4130d5f9184..2e9daed41e4 100644
--- a/spec/lib/gitlab/experimentation_spec.rb
+++ b/spec/lib/gitlab/experimentation_spec.rb
@@ -28,7 +28,7 @@ RSpec.describe Gitlab::Experimentation::EXPERIMENTS do
end
end
-RSpec.describe Gitlab::Experimentation, :snowplow do
+RSpec.describe Gitlab::Experimentation do
before do
stub_const('Gitlab::Experimentation::EXPERIMENTS', {
backwards_compatible_test_experiment: {
@@ -47,92 +47,131 @@ RSpec.describe Gitlab::Experimentation, :snowplow do
let(:enabled_percentage) { 10 }
- describe '.enabled?' do
- subject { described_class.enabled?(:test_experiment) }
+ describe '.get_experiment' do
+ subject { described_class.get_experiment(:test_experiment) }
- context 'feature toggle is enabled and we are selected' do
- it { is_expected.to be_truthy }
+ context 'returns experiment' do
+ it { is_expected.to be_instance_of(Gitlab::Experimentation::Experiment) }
+ end
+
+ context 'experiment is not defined' do
+ subject { described_class.get_experiment(:missing_experiment) }
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ describe '.active?' do
+ subject { described_class.active?(:test_experiment) }
+
+ context 'feature toggle is enabled' do
+ it { is_expected.to eq(true) }
end
describe 'experiment is not defined' do
it 'returns false' do
- expect(described_class.enabled?(:missing_experiment)).to be_falsey
+ expect(described_class.active?(:missing_experiment)).to eq(false)
end
end
describe 'experiment is disabled' do
let(:enabled_percentage) { 0 }
- it { is_expected.to be_falsey }
+ it { is_expected.to eq(false) }
end
end
- describe '.enabled_for_value?' do
- subject { described_class.enabled_for_value?(:test_experiment, experimentation_subject_index) }
+ describe '.in_experiment_group?' do
+ context 'with new index calculation' do
+ let(:enabled_percentage) { 50 }
+ let(:experiment_subject) { 'z' } # Zlib.crc32('test_experimentz') % 100 = 33
- let(:experimentation_subject_index) { 9 }
-
- context 'experiment is disabled' do
- before do
- allow(described_class).to receive(:enabled?).and_return(false)
- end
+ subject { described_class.in_experiment_group?(:test_experiment, subject: experiment_subject) }
- it { is_expected.to be_falsey }
- end
+ context 'when experiment is active' do
+ context 'when subject is part of the experiment' do
+ it { is_expected.to eq(true) }
+ end
- context 'experiment is enabled' do
- before do
- allow(described_class).to receive(:enabled?).and_return(true)
- end
+ context 'when subject is not part of the experiment' do
+ let(:experiment_subject) { 'a' } # Zlib.crc32('test_experimenta') % 100 = 61
- it { is_expected.to be_truthy }
+ it { is_expected.to eq(false) }
+ end
- describe 'experimentation_subject_index' do
- context 'experimentation_subject_index is not set' do
- let(:experimentation_subject_index) { nil }
+ context 'when subject has a global_id' do
+ let(:experiment_subject) { double(:subject, to_global_id: 'z') }
- it { is_expected.to be_falsey }
+ it { is_expected.to eq(true) }
end
- context 'experimentation_subject_index is an empty string' do
- let(:experimentation_subject_index) { '' }
+ context 'when subject is nil' do
+ let(:experiment_subject) { nil }
- it { is_expected.to be_falsey }
+ it { is_expected.to eq(false) }
end
- context 'experimentation_subject_index outside enabled ratio' do
- let(:experimentation_subject_index) { 11 }
+ context 'when subject is an empty string' do
+ let(:experiment_subject) { '' }
- it { is_expected.to be_falsey }
+ it { is_expected.to eq(false) }
end
end
+
+ context 'when experiment is not active' do
+ before do
+ allow(described_class).to receive(:active?).and_return(false)
+ end
+
+ it { is_expected.to eq(false) }
+ end
end
- end
- describe '.enabled_for_attribute?' do
- subject { described_class.enabled_for_attribute?(:test_experiment, attribute) }
+ context 'with backwards compatible index calculation' do
+ let(:experiment_subject) { 'abcd' } # Digest::SHA1.hexdigest('abcd').hex % 100 = 7
- let(:attribute) { 'abcd' } # Digest::SHA1.hexdigest('abcd').hex % 100 = 7
+ subject { described_class.in_experiment_group?(:backwards_compatible_test_experiment, subject: experiment_subject) }
- context 'experiment is disabled' do
- before do
- allow(described_class).to receive(:enabled?).and_return(false)
- end
+ context 'when experiment is active' do
+ before do
+ allow(described_class).to receive(:active?).and_return(true)
+ end
- it { is_expected.to be false }
- end
+ context 'when subject is part of the experiment' do
+ it { is_expected.to eq(true) }
+ end
- context 'experiment is enabled' do
- before do
- allow(described_class).to receive(:enabled?).and_return(true)
- end
+ context 'when subject is not part of the experiment' do
+ let(:experiment_subject) { 'abc' } # Digest::SHA1.hexdigest('abc').hex % 100 = 17
- it { is_expected.to be true }
+ it { is_expected.to eq(false) }
+ end
+
+ context 'when subject has a global_id' do
+ let(:experiment_subject) { double(:subject, to_global_id: 'abcd') }
+
+ it { is_expected.to eq(true) }
+ end
- context 'outside enabled ratio' do
- let(:attribute) { 'abc' } # Digest::SHA1.hexdigest('abc').hex % 100 = 17
+ context 'when subject is nil' do
+ let(:experiment_subject) { nil }
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'when subject is an empty string' do
+ let(:experiment_subject) { '' }
+
+ it { is_expected.to eq(false) }
+ end
+ end
+
+ context 'when experiment is not active' do
+ before do
+ allow(described_class).to receive(:active?).and_return(false)
+ end
- it { is_expected.to be false }
+ it { is_expected.to eq(false) }
end
end
end
diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index 382f29e1bed..b4688cc02c4 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -1453,7 +1453,7 @@ RSpec.describe Notify do
shared_examples "tracks the 'sent' event for the invitation reminders experiment" do
before do
stub_experiment(invitation_reminders: true)
- allow(Gitlab::Experimentation).to receive(:enabled_for_attribute?).with(:invitation_reminders, group_member.invite_email).and_return(experimental_group)
+ allow(Gitlab::Experimentation).to receive(:in_experiment_group?).with(:invitation_reminders, subject: group_member.invite_email).and_return(experimental_group)
end
it "tracks the 'sent' event", :snowplow do
diff --git a/spec/migrations/populate_remaining_missing_dismissal_information_for_vulnerabilities_spec.rb b/spec/migrations/populate_remaining_missing_dismissal_information_for_vulnerabilities_spec.rb
new file mode 100644
index 00000000000..986436971ac
--- /dev/null
+++ b/spec/migrations/populate_remaining_missing_dismissal_information_for_vulnerabilities_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe PopulateRemainingMissingDismissalInformationForVulnerabilities do
+ let(:users) { table(:users) }
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:vulnerabilities) { table(:vulnerabilities) }
+
+ let(:user) { users.create!(name: 'test', email: 'test@example.com', projects_limit: 5) }
+ let(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org') }
+ let(:project) { projects.create!(namespace_id: namespace.id, name: 'foo') }
+
+ let(:states) { { detected: 1, dismissed: 2, resolved: 3, confirmed: 4 } }
+ let!(:vulnerability_1) { vulnerabilities.create!(title: 'title', state: states[:detected], severity: 0, confidence: 5, report_type: 2, project_id: project.id, author_id: user.id) }
+ let!(:vulnerability_2) { vulnerabilities.create!(title: 'title', state: states[:dismissed], severity: 0, confidence: 5, report_type: 2, project_id: project.id, author_id: user.id) }
+ let!(:vulnerability_3) { vulnerabilities.create!(title: 'title', state: states[:resolved], severity: 0, confidence: 5, report_type: 2, project_id: project.id, author_id: user.id) }
+ let!(:vulnerability_4) { vulnerabilities.create!(title: 'title', state: states[:confirmed], severity: 0, confidence: 5, report_type: 2, project_id: project.id, author_id: user.id) }
+
+ describe '#perform' do
+ it 'calls the background migration class instance with broken vulnerability IDs' do
+ expect_next_instance_of(::Gitlab::BackgroundMigration::PopulateMissingVulnerabilityDismissalInformation) do |migrator|
+ expect(migrator).to receive(:perform).with(vulnerability_2.id)
+ end
+
+ migrate!
+ end
+ end
+end
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index 9e1ed05f896..09bad8694fc 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -500,6 +500,77 @@ RSpec.describe MergeRequest, factory_default: :keep do
end
end
+ describe 'time to merge calculations' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+
+ let!(:mr1) do
+ create(
+ :merge_request,
+ :with_merged_metrics,
+ source_project: project,
+ target_project: project
+ )
+ end
+
+ let!(:mr2) do
+ create(
+ :merge_request,
+ :with_merged_metrics,
+ source_project: project,
+ target_project: project
+ )
+ end
+
+ let!(:mr3) do
+ create(
+ :merge_request,
+ :with_merged_metrics,
+ source_project: project,
+ target_project: project
+ )
+ end
+
+ let!(:unmerged_mr) do
+ create(
+ :merge_request,
+ source_project: project,
+ target_project: project
+ )
+ end
+
+ before do
+ project.add_user(user, :developer)
+ end
+
+ describe '.total_time_to_merge' do
+ it 'returns the sum of the time to merge for all merged MRs' do
+ mrs = project.merge_requests
+
+ expect(mrs.total_time_to_merge).to be_within(1).of(expected_total_time(mrs))
+ end
+
+ context 'when merged_at is earlier than created_at' do
+ before do
+ mr1.metrics.update!(merged_at: mr1.metrics.created_at - 1.week)
+ end
+
+ it 'returns nil' do
+ mrs = project.merge_requests.where(id: mr1.id)
+
+ expect(mrs.total_time_to_merge).to be_nil
+ end
+ end
+
+ def expected_total_time(mrs)
+ mrs = mrs.reject { |mr| mr.merged_at.nil? }
+ mrs.reduce(0.0) do |sum, mr|
+ (mr.merged_at - mr.created_at) + sum
+ end
+ end
+ end
+ end
+
describe '#target_branch_sha' do
let(:project) { create(:project, :repository) }
diff --git a/spec/policies/global_policy_spec.rb b/spec/policies/global_policy_spec.rb
index c8401e4acff..e677f5558fd 100644
--- a/spec/policies/global_policy_spec.rb
+++ b/spec/policies/global_policy_spec.rb
@@ -150,6 +150,24 @@ RSpec.describe GlobalPolicy do
end
end
+ describe 'rejecting users' do
+ context 'regular user' do
+ it { is_expected.not_to be_allowed(:reject_user) }
+ end
+
+ context 'admin' do
+ let(:current_user) { create(:admin) }
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed(:reject_user) }
+ end
+
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_disallowed(:reject_user) }
+ end
+ end
+ end
+
describe 'using project statistics filters' do
context 'regular user' do
it { is_expected.not_to be_allowed(:use_project_statistics_filters) }
diff --git a/spec/services/members/invitation_reminder_email_service_spec.rb b/spec/services/members/invitation_reminder_email_service_spec.rb
index 88280869476..7cb30662152 100644
--- a/spec/services/members/invitation_reminder_email_service_spec.rb
+++ b/spec/services/members/invitation_reminder_email_service_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe Members::InvitationReminderEmailService do
context 'when the experiment is disabled' do
before do
- allow(Gitlab::Experimentation).to receive(:enabled_for_attribute?).and_return(false)
+ allow(Gitlab::Experimentation).to receive(:in_experiment_group?).and_return(false)
invitation.expires_at = frozen_time + 2.days
end
@@ -26,7 +26,7 @@ RSpec.describe Members::InvitationReminderEmailService do
context 'when the experiment is enabled' do
before do
- allow(Gitlab::Experimentation).to receive(:enabled_for_attribute?).and_return(true)
+ allow(Gitlab::Experimentation).to receive(:in_experiment_group?).and_return(true)
invitation.expires_at = frozen_time + expires_at_days.days if expires_at_days
end
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index 34a95c0505d..28577821231 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -2326,6 +2326,20 @@ RSpec.describe NotificationService, :mailer do
end
end
+ describe '#user_admin_rejection', :deliver_mails_inline do
+ let_it_be(:user) { create(:user, :blocked_pending_approval) }
+
+ before do
+ reset_delivered_emails!
+ end
+
+ it 'sends the user a rejection email' do
+ notification.user_admin_rejection(user.name, user.email)
+
+ should_only_email(user)
+ end
+ end
+
describe 'GroupMember', :deliver_mails_inline do
let(:added_user) { create(:user) }
diff --git a/spec/services/users/reject_service_spec.rb b/spec/services/users/reject_service_spec.rb
new file mode 100644
index 00000000000..07863d1a290
--- /dev/null
+++ b/spec/services/users/reject_service_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Users::RejectService do
+ let_it_be(:current_user) { create(:admin) }
+ let(:user) { create(:user, :blocked_pending_approval) }
+
+ subject(:execute) { described_class.new(current_user).execute(user) }
+
+ describe '#execute' do
+ context 'failures' do
+ context 'when the executor user is not allowed to reject users' do
+ let(:current_user) { create(:user) }
+
+ it 'returns error result' do
+ expect(subject[:status]).to eq(:error)
+ expect(subject[:message]).to match(/You are not allowed to reject a user/)
+ end
+ end
+
+ context 'when the executor user is an admin in admin mode', :enable_admin_mode do
+ context 'when user is not in pending approval state' do
+ let(:user) { create(:user, state: 'active') }
+
+ it 'returns error result' do
+ expect(subject[:status]).to eq(:error)
+ expect(subject[:message])
+ .to match(/This user does not have a pending request/)
+ end
+ end
+ end
+ end
+
+ context 'success' do
+ context 'when the executor user is an admin in admin mode', :enable_admin_mode do
+ it 'deletes the user', :sidekiq_inline do
+ subject
+
+ expect(subject[:status]).to eq(:success)
+ expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+
+ it 'emails the user on rejection' do
+ expect_next_instance_of(NotificationService) do |notification|
+ allow(notification).to receive(:user_admin_rejection).with(user.name, user.notification_email)
+ end
+
+ subject
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/helpers/stub_experiments.rb b/spec/support/helpers/stub_experiments.rb
index 7a6154d5ef9..247692d83ee 100644
--- a/spec/support/helpers/stub_experiments.rb
+++ b/spec/support/helpers/stub_experiments.rb
@@ -3,15 +3,15 @@
module StubExperiments
# Stub Experiment with `key: true/false`
#
- # @param [Hash] experiment where key is feature name and value is boolean whether enabled or not.
+ # @param [Hash] experiment where key is feature name and value is boolean whether active or not.
#
# Examples
- # - `stub_experiment(signup_flow: false)` ... Disable `signup_flow` experiment globally.
+ # - `stub_experiment(signup_flow: false)` ... Disables `signup_flow` experiment.
def stub_experiment(experiments)
- allow(Gitlab::Experimentation).to receive(:enabled?).and_call_original
+ allow(Gitlab::Experimentation).to receive(:active?).and_call_original
experiments.each do |experiment_key, enabled|
- allow(Gitlab::Experimentation).to receive(:enabled?).with(experiment_key) { enabled }
+ allow(Gitlab::Experimentation).to receive(:active?).with(experiment_key) { enabled }
end
end
@@ -20,12 +20,12 @@ module StubExperiments
# @param [Hash] experiment where key is feature name and value is boolean whether enabled or not.
#
# Examples
- # - `stub_experiment_for_user(signup_flow: false)` ... Disable `signup_flow` experiment for user.
- def stub_experiment_for_user(experiments)
- allow(Gitlab::Experimentation).to receive(:enabled_for_value?).and_call_original
+ # - `stub_experiment_for_subject(signup_flow: false)` ... Disable `signup_flow` experiment for user.
+ def stub_experiment_for_subject(experiments)
+ allow(Gitlab::Experimentation).to receive(:in_experiment_group?).and_call_original
experiments.each do |experiment_key, enabled|
- allow(Gitlab::Experimentation).to receive(:enabled_for_value?).with(experiment_key, anything) { enabled }
+ allow(Gitlab::Experimentation).to receive(:in_experiment_group?).with(experiment_key, anything) { enabled }
end
end
end
diff --git a/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb b/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb
index ac1cc2da7e3..3fec1a56c0c 100644
--- a/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb
+++ b/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb
@@ -3,7 +3,7 @@
RSpec.shared_examples 'issuable invite members experiments' do
context 'when invite_members_version_a experiment is enabled' do
before do
- stub_experiment_for_user(invite_members_version_a: true)
+ stub_experiment_for_subject(invite_members_version_a: true)
end
it 'shows a link for inviting members and follows through to the members page' do
@@ -28,7 +28,7 @@ RSpec.shared_examples 'issuable invite members experiments' do
context 'when invite_members_version_b experiment is enabled' do
before do
- stub_experiment_for_user(invite_members_version_b: true)
+ stub_experiment_for_subject(invite_members_version_b: true)
end
it 'shows a link for inviting members and follows through to modal' do