Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-12-03 00:09:53 +00:00
parent f96f2720d1
commit 1502c20d04
93 changed files with 1497 additions and 320 deletions

View File

@ -61,7 +61,13 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions(['setOverride', 'setIsSaving', 'setIsTesting', 'setIsResetting']), ...mapActions([
'setOverride',
'setIsSaving',
'setIsTesting',
'setIsResetting',
'fetchResetIntegration',
]),
onSaveClick() { onSaveClick() {
this.setIsSaving(true); this.setIsSaving(true);
eventHub.$emit('saveIntegration'); eventHub.$emit('saveIntegration');
@ -70,7 +76,9 @@ export default {
this.setIsTesting(true); this.setIsTesting(true);
eventHub.$emit('testIntegration'); eventHub.$emit('testIntegration');
}, },
onResetClick() {}, onResetClick() {
this.fetchResetIntegration();
},
}, },
}; };
</script> </script>

View File

@ -1,3 +1,5 @@
import axios from 'axios';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import * as types from './mutation_types'; import * as types from './mutation_types';
export const setOverride = ({ commit }, override) => commit(types.SET_OVERRIDE, override); 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 setIsTesting = ({ commit }, isTesting) => commit(types.SET_IS_TESTING, isTesting);
export const setIsResetting = ({ commit }, isResetting) => export const setIsResetting = ({ commit }, isResetting) =>
commit(types.SET_IS_RESETTING, 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'));
};

View File

@ -2,3 +2,6 @@ export const SET_OVERRIDE = 'SET_OVERRIDE';
export const SET_IS_SAVING = 'SET_IS_SAVING'; export const SET_IS_SAVING = 'SET_IS_SAVING';
export const SET_IS_TESTING = 'SET_IS_TESTING'; export const SET_IS_TESTING = 'SET_IS_TESTING';
export const SET_IS_RESETTING = 'SET_IS_RESETTING'; 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';

View File

@ -13,4 +13,10 @@ export default {
[types.SET_IS_RESETTING](state, isResetting) { [types.SET_IS_RESETTING](state, isResetting) {
state.isResetting = isResetting; state.isResetting = isResetting;
}, },
[types.REQUEST_RESET_INTEGRATION](state) {
state.isResetting = true;
},
[types.RECEIVE_RESET_INTEGRATION_ERROR](state) {
state.isResetting = false;
},
}; };

View File

@ -1,3 +1,9 @@
import { unwrapStagesWithNeeds } from '../unwrapping_utils';
const addMulti = (mainId, pipeline) => {
return { ...pipeline, multiproject: mainId !== pipeline.id };
};
const unwrapPipelineData = (mainPipelineId, data) => { const unwrapPipelineData = (mainPipelineId, data) => {
if (!data?.project?.pipeline) { if (!data?.project?.pipeline) {
return null; return null;
@ -10,35 +16,13 @@ const unwrapPipelineData = (mainPipelineId, data) => {
stages: { nodes: stages }, stages: { nodes: stages },
} = data.project.pipeline; } = data.project.pipeline;
const unwrappedNestedGroups = stages.map(stage => { const nodes = unwrapStagesWithNeeds(stages);
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 };
};
return { return {
id, id,
stages: nodes, stages: nodes,
upstream: upstream ? [upstream].map(addMulti) : [], upstream: upstream ? [upstream].map(addMulti.bind(null, mainPipelineId)) : [],
downstream: downstream ? downstream.map(addMulti) : [], downstream: downstream ? downstream.map(addMulti.bind(null, mainPipelineId)) : [],
}; };
}; };

View File

@ -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 };

View File

@ -72,6 +72,16 @@ class Admin::UsersController < Admin::ApplicationController
end end
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 def activate
return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user must be unblocked to be activated")) if user.blocked? return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user must be unblocked to be activated")) if user.blocked?

View File

@ -43,6 +43,12 @@ module IntegrationsActions
render json: {}, status: :ok render json: {}, status: :ok
end end
def reset
flash[:notice] = s_('Integrations|This integration, and inheriting projects were reset.')
render json: {}, status: :ok
end
private private
def integrations_enabled? def integrations_enabled?

View File

@ -109,15 +109,6 @@ class InvitesController < ApplicationController
end end
def track_invitation_reminders_experiment(action) def track_invitation_reminders_experiment(action)
return unless Gitlab::Experimentation.enabled?(:invitation_reminders) track_experiment_event(:invitation_reminders, action, subject: member)
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)
)
end end
end end

View File

@ -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

View File

@ -4,7 +4,7 @@ module Types
class MergeRequestType < BaseObject class MergeRequestType < BaseObject
graphql_name 'MergeRequest' graphql_name 'MergeRequest'
connection_type_class(Types::CountableConnectionType) connection_type_class(Types::MergeRequestConnectionType)
implements(Types::Notes::NoteableType) implements(Types::Notes::NoteableType)
implements(Types::CurrentUserTodos) implements(Types::CurrentUserTodos)

View File

@ -75,7 +75,15 @@ module ServicesHelper
end end
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, id: integration.id,
show_active: integration.show_active_box?.to_s, show_active: integration.show_active_box?.to_s,
@ -94,7 +102,7 @@ module ServicesHelper
cancel_path: scoped_integrations_path, cancel_path: scoped_integrations_path,
can_test: integration.can_test?.to_s, can_test: integration.can_test?.to_s,
test_path: scoped_test_integration_path(integration), test_path: scoped_test_integration_path(integration),
reset_path: '' reset_path: reset_integrations?(group: group) ? scoped_reset_integration_path(integration, group: group) : ''
} }
end end
@ -122,6 +130,10 @@ module ServicesHelper
!Gitlab.com? !Gitlab.com?
end end
def reset_integrations?(group: nil)
Feature.enabled?(:reset_integrations, group, type: :development)
end
extend self extend self
private private

View File

@ -64,11 +64,11 @@ module Emails
layout: 'unknown_user_mailer' layout: 'unknown_user_mailer'
) )
if Gitlab::Experimentation.enabled?(:invitation_reminders) if Gitlab::Experimentation.active?(:invitation_reminders)
Gitlab::Tracking.event( Gitlab::Tracking.event(
Gitlab::Experimentation.experiment(:invitation_reminders).tracking_category, Gitlab::Experimentation.get_experiment(:invitation_reminders).tracking_category,
'sent', '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) label: Digest::MD5.hexdigest(member.to_global_id.to_s)
) )
end end

View File

@ -18,6 +18,14 @@ module Emails
subject: subject(_("GitLab Account Request"))) subject: subject(_("GitLab Account Request")))
end 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 # rubocop: disable CodeReuse/ActiveRecord
def new_ssh_key_email(key_id) def new_ssh_key_email(key_id)
@key = Key.find_by(id: key_id) @key = Key.find_by(id: key_id)

View File

@ -339,6 +339,13 @@ class MergeRequest < ApplicationRecord
) )
end 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? after_save :keep_around_commit, unless: :importing?
alias_attribute :project, :target_project alias_attribute :project, :target_project

View File

@ -10,6 +10,11 @@ class MergeRequest::Metrics < ApplicationRecord
scope :merged_after, ->(date) { where(arel_table[:merged_at].gteq(date)) } scope :merged_after, ->(date) { where(arel_table[:merged_at].gteq(date)) }
scope :merged_before, ->(date) { where(arel_table[:merged_at].lteq(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 private

View File

@ -99,6 +99,7 @@ class GlobalPolicy < BasePolicy
enable :read_custom_attribute enable :read_custom_attribute
enable :update_custom_attribute enable :update_custom_attribute
enable :approve_user enable :approve_user
enable :reject_user
end end
# We can't use `read_statistics` because the user may have different permissions for different projects # We can't use `read_statistics` because the user may have different permissions for different projects

View File

@ -25,7 +25,7 @@ module Members
private private
def experiment_enabled? 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 end
def days_after_invitation_sent def days_after_invitation_sent

View File

@ -380,6 +380,10 @@ class NotificationService
end end
end end
def user_admin_rejection(name, email)
mailer.user_admin_rejection_email(name, email).deliver_later
end
# Members # Members
def new_access_request(member) def new_access_request(member)
return true unless member.notifiable?(:subscription) return true unless member.notifiable?(:subscription)

View File

@ -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

View File

@ -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?') }

View File

@ -37,8 +37,7 @@
- elsif user.blocked? - elsif user.blocked?
- if user.blocked_pending_approval? - if user.blocked_pending_approval?
= link_to s_('AdminUsers|Approve'), approve_admin_user_path(user), method: :put = 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) } = link_to s_('AdminUsers|Reject'), reject_admin_user_path(user), method: :delete
= s_('AdminUsers|Block')
- else - else
%button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_unblock_data(user) } %button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_unblock_data(user) }
= s_('AdminUsers|Unblock') = s_('AdminUsers|Unblock')
@ -56,7 +55,7 @@
- if user.access_locked? - if user.access_locked?
%li %li
= link_to _('Unlock'), unlock_admin_user_path(user), method: :put, data: { confirm: _('Are you sure?') } = 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 %li.divider
- if user.can_be_removed? - if user.can_be_removed?
%li %li

View File

@ -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 }

View File

@ -172,7 +172,7 @@
- if @user.blocked? - if @user.blocked?
- if @user.blocked_pending_approval? - if @user.blocked_pending_approval?
= render 'admin/users/approve_user', user: @user = render 'admin/users/approve_user', user: @user
= render 'admin/users/block_user', user: @user = render 'admin/users/reject_pending_user', user: @user
- else - else
.card.border-info .card.border-info
.card-header.gl-bg-blue-500.gl-text-white .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. %p This user has been temporarily locked due to excessive number of failed logins. You may manually unlock the account.
%br %br
= link_to 'Unlock user', unlock_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: 'Are you sure?' } = link_to 'Unlock user', unlock_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: 'Are you sure?' }
- 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.border-danger
.card-header.bg-danger.text-white .card-header.bg-danger.text-white
= s_('AdminUsers|Delete user') = s_('AdminUsers|Delete user and contributions')
.card-body .card-body
- if @user.can_be_removed? && can?(current_user, :destroy_user, @user) - if 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 %p
This user is currently an owner in these groups: This option deletes the user and any contributions that
%strong= @user.solo_owned_groups.map(&:name).join(', ') would usually be moved to the
%p = succeed "." do
You must transfer ownership or delete these groups before you can delete this user. = 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 - else
%p %p
You don't have access to delete this user. 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' = render partial: 'admin/users/modals'

View File

@ -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.')

View File

@ -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.') %>

View File

@ -9,5 +9,5 @@
.service-settings .service-settings
- if @default_integration - if @default_integration
.js-vue-default-integration-settings{ data: integration_form_data(@default_integration) } .js-vue-default-integration-settings{ data: integration_form_data(@default_integration, group: @group) }
.js-vue-integration-settings{ data: integration_form_data(integration) } .js-vue-integration-settings{ data: integration_form_data(integration, group: @group) }

View File

@ -8,7 +8,7 @@ class MemberInvitationReminderEmailsWorker # rubocop:disable Scalability/Idempot
urgency :low urgency :low
def perform 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| Member.not_accepted_invitations.not_expired.last_ten_days_excluding_today.find_in_batches do |invitations|
invitations.each do |invitation| invitations.each do |invitation|

View File

@ -0,0 +1,6 @@
---
title: Add index for the `vulnerabilities` table on `project_id`, `state`, and `severity`
columns
merge_request: 48930
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Add migration to populate remaining dismissal information for vulnerabilities
merge_request: 48472
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Email user when registration request is rejected
merge_request: 48185
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Add merge requests total time to merge field to the GraphQL API
merge_request: 46040
author:
type: added

View File

@ -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

View File

@ -18,6 +18,7 @@ namespace :admin do
put :unlock put :unlock
put :confirm put :confirm
put :approve put :approve
delete :reject
post :impersonate post :impersonate
patch :disable_two_factor patch :disable_two_factor
delete 'remove/:email_id', action: 'remove_email', as: 'remove_email' delete 'remove/:email_id', action: 'remove_email', as: 'remove_email'
@ -126,6 +127,7 @@ namespace :admin do
resources :integrations, only: [:edit, :update] do resources :integrations, only: [:edit, :update] do
member do member do
put :test put :test
post :reset
end end
end end

View File

@ -46,6 +46,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
resources :integrations, only: [:index, :edit, :update] do resources :integrations, only: [:index, :edit, :update] do
member do member do
put :test put :test
post :reset
end end
end end
end end

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
bde71afbe34006eedbd97ac457df31b247fc89a572ca8900c60b16c4d6a8ef93

View File

@ -0,0 +1 @@
1113642dfc9069dcea01ac12b1653dfcf67b3aea449538e9747d3bc857ce88d8

View File

@ -0,0 +1 @@
54ed18361a28d0b750cbbdb3bfb53b7e4bbe3d1d7264de51522796d3bd15f7a5

View File

@ -0,0 +1 @@
cb11dc9996b1706feaa8a53f96cbaa6209a4d07b3be9e88ebc3d1e1ada561287

View File

@ -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_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 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); 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_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); CREATE INDEX index_vulnerabilities_on_resolved_by_id ON vulnerabilities USING btree (resolved_by_id);

View File

@ -13050,6 +13050,11 @@ type MergeRequestConnection {
Information to aid in pagination. Information to aid in pagination.
""" """
pageInfo: PageInfo! pageInfo: PageInfo!
"""
Total sum of time to merge, in seconds, for the collection of merge requests
"""
totalTimeToMerge: Float
} }
""" """

View File

@ -36011,6 +36011,20 @@
}, },
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "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, "inputFields": null,

View File

@ -73,7 +73,7 @@ to your needs:
![Use a `.gitlab-ci.yml` template](img/add_file_template_11_10.png) ![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. For a broader overview, see the [CI/CD getting started](quick_start/README.md) guide.

View File

@ -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.

View File

@ -72,6 +72,10 @@ graph TB
- (Optional) Sending notifications through ActionCable for events received from `agentk`. - (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. - 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 ## Guiding principles
GitLab prefers to add logic into `kas` rather than `agentk`. `agentk` should be kept GitLab prefers to add logic into `kas` rather than `agentk`. `agentk` should be kept

View File

@ -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).

View File

@ -434,7 +434,7 @@ GitLab CI/CD is the open-source continuous integration service included with Git
#### GitLab Shell #### 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: - Configuration:
- [Omnibus](https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/files/gitlab-config-template/gitlab.rb.template) - [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/) - [Charts](https://docs.gitlab.com/charts/charts/gitlab/gitlab-shell/)

View File

@ -56,59 +56,90 @@ addressed.
1. Use the experiment in the code. 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: - Use this standard for the experiment in a controller:
```ruby Experiment run for a user:
class RegistrationController < ApplicationController
```ruby
class ProjectController < ApplicationController
def show def show
# experiment_enabled?(:experiment_key) is also available in views and helpers # 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) if experiment_enabled?(:signup_flow)
# render the experiment # render the experiment
else else
# render the original version # render the original version
end end
end end
end end
``` ```
- Make the experiment available to the frontend in a controller: - Make the experiment available to the frontend in a controller:
```ruby ```ruby
before_action do before_action do
push_frontend_experiment(:signup_flow) push_frontend_experiment(:signup_flow, subject: current_user)
end 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 ```javascript
import { isExperimentEnabled } from '~/experimentation'; 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: - It is also possible to run an experiment outside of the controller scope, for example in a worker:
```ruby ```ruby
class SomeWorker class SomeWorker
def perform def perform
# Check if the experiment is enabled at all (the percentage_of_time_value > 0) # Check if the experiment is active at all (the percentage_of_time_value > 0)
return unless Gitlab::Experimentation.enabled?(:experiment_key) 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. # 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: # It is therefore necessery to always provide the same subject.
if Gitlab::Experimentation.enabled_for_attribute?(:experiment_key, some_attribute) if Gitlab::Experimentation.in_experiment_group?(:experiment_key, subject: user)
# execute experimental code # execute experimental code
else else
# execute control code # execute control code
end end
end end
end end
``` ```
### Implement the tracking events ### Implement the tracking events
@ -122,7 +153,7 @@ The framework provides the following helper method that is available in controll
```ruby ```ruby
before_action do before_action do
track_experiment_event(:signup_flow, 'action', 'value') track_experiment_event(:signup_flow, 'action', 'value', subject: current_user)
end 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 context 'when the experiment is active and the user is in the experimental group' do
before do before do
stub_experiment(signup_flow: true) stub_experiment(signup_flow: true)
stub_experiment_for_user(signup_flow: true) stub_experiment_for_subject(signup_flow: true)
end end
it 'tracks an event', :snowplow do it 'tracks an event', :snowplow do
@ -155,8 +186,8 @@ The framework provides the following helper method that is available in controll
```ruby ```ruby
before_action do before_action do
push_frontend_experiment(:signup_flow) push_frontend_experiment(:signup_flow, subject: current_user)
frontend_experimentation_tracking_data(:signup_flow, 'action', 'value') frontend_experimentation_tracking_data(:signup_flow, 'action', 'value', subject: current_user)
end 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. - **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. - **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 ```ruby
before_action do before_action do
@ -296,7 +327,7 @@ context 'when the experiment is active' do
context 'when the user is in the experimental group' do context 'when the user is in the experimental group' do
before do before do
stub_experiment_for_user(signup_flow: true) stub_experiment_for_subject(signup_flow: true)
end end
it { is_expected.to do_experimental_thing } 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 context 'when the user is in the control group' do
before do before do
stub_experiment_for_user(signup_flow: false) stub_experiment_for_subject(signup_flow: false)
end end
it { is_expected.to do_control_thing } it { is_expected.to do_control_thing }

View File

@ -503,10 +503,6 @@ is persisted.
Make sure behavior under feature flag doesn't go untested in some non-specific contexts. 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` ### `stub_feature_flags: false`
This disables a memory-stubbed flipper, and uses `Flipper::Adapters::ActiveRecord` This disables a memory-stubbed flipper, and uses `Flipper::Adapters::ActiveRecord`

View File

@ -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/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. - `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.

View File

@ -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 - `remove_with`: set to a GitLab release typically two releases (M+2) after adding the
column ignore. column ignore.
- `remove_after`: set to a date after which we consider it safe to remove the column - `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 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 don't remove column ignores too early for both regular releases and deployments to GitLab.com. For

View File

@ -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. 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 This allows the user to create a [confidential issue](../../project/issues/confidential_issues.md)
project the vulnerability came from and pre-populates it with useful information from in the project the vulnerability came from. Fields are pre-populated with pertinent information
the vulnerability report. After the issue is created, GitLab redirects you to the 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. issue page so you can edit, assign, or comment on the issue.
## Link issues to the vulnerability ## Link issues to the vulnerability

View File

@ -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. - 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. - 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**: For example, to unlink the `MyOrg` account, the following **Disconnect** button is available under **Profile > Accounts**:
![Unlink Group SAML](img/unlink_group_saml.png) ![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` is linked as `Guest` and another `Maintainer`, a user in both groups gets `Maintainer`
access. 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 ## Glossary
| Term | Description | | Term | Description |

View File

@ -159,6 +159,7 @@ The following table depicts the various user permission levels in a project.
| Manage Terraform state | | | | ✓ | ✓ | | Manage Terraform state | | | | ✓ | ✓ |
| Manage license policy **(ULTIMATE)** | | | | ✓ | ✓ | | Manage license policy **(ULTIMATE)** | | | | ✓ | ✓ |
| Edit comments (posted by any user) | | | | ✓ | ✓ | | Edit comments (posted by any user) | | | | ✓ | ✓ |
| Reposition comments on images (posted by any user)|✓ (*11*) | ✓ (*11*) | ✓ (*11*) | ✓ | ✓ |
| Manage Error Tracking | | | | ✓ | ✓ | | Manage Error Tracking | | | | ✓ | ✓ |
| Delete wiki pages | | | | ✓ | ✓ | | Delete wiki pages | | | | ✓ | ✓ |
| View project Audit Events | | | | ✓ | ✓ | | 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 1. For information on eligible approvers for merge requests, see
[Eligible approvers](project/merge_requests/merge_request_approvals.md#eligible-approvers). [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. 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 ## 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 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. 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
Project features like wiki and issues can be hidden from users depending on Project features like wiki and issues can be hidden from users depending on

View File

@ -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> <i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
For an overview, see [Group Deploy Tokens](https://youtu.be/8kxTJvaD9ks). 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: To use a group deploy token:
1. [Create](#creating-a-deploy-token) a deploy token for a group. 1. [Create](#creating-a-deploy-token) a deploy token for a group.

View File

@ -198,8 +198,8 @@ To rename a repository:
1. Navigate to your project's **Settings > General**. 1. Navigate to your project's **Settings > General**.
1. Under **Advanced**, click **Expand**. 1. Under **Advanced**, click **Expand**.
1. Under "Rename repository", change the "Path" to your liking. 1. Under **Change path**, update the repository's path.
1. Hit **Rename project**. 1. Click **Change path**.
Remember that this can have unintended side effects since everyone with the 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 old URL won't be able to push or pull. Read more about what happens with the

View File

@ -87,23 +87,49 @@ module Gitlab
}.freeze }.freeze
class << self class << self
def experiment(key) def get_experiment(experiment_key)
Gitlab::Experimentation::Experiment.new(key, **EXPERIMENTS[key]) return unless EXPERIMENTS.key?(experiment_key)
::Gitlab::Experimentation::Experiment.new(experiment_key, **EXPERIMENTS[experiment_key])
end end
def enabled?(experiment_key) def active?(experiment_key)
return false unless EXPERIMENTS.key?(experiment_key) experiment = get_experiment(experiment_key)
return false unless experiment
experiment(experiment_key).enabled? experiment.active?
end end
def enabled_for_attribute?(experiment_key, attribute) def in_experiment_group?(experiment_key, subject:)
index = Digest::SHA1.hexdigest(attribute).hex % 100 return false if subject.blank?
enabled_for_value?(experiment_key, index) 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 end
def enabled_for_value?(experiment_key, value) private
enabled?(experiment_key) && experiment(experiment_key).enabled_for_index?(value)
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 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 end
end end

View File

@ -3,7 +3,7 @@
require 'zlib' require 'zlib'
# Controller concern that checks if an `experimentation_subject_id cookie` is present and sets it if absent. # 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 # to controllers and views. It returns true when the experiment is enabled and the user is selected as part
# of the experimental group. # of the experimental group.
# #
@ -28,55 +28,56 @@ module Gitlab
} }
end end
def push_frontend_experiment(experiment_key) def push_frontend_experiment(experiment_key, subject: nil)
var_name = experiment_key.to_s.camelize(:lower) 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) gon.push({ experiments: { var_name => enabled } }, true)
end 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 false if dnt_enabled?
return true if Experimentation.enabled_for_value?(experiment_key, experimentation_subject_index(experiment_key)) subject ||= fallback_experimentation_subject_index(experiment_key)
return true if forced_enabled?(experiment_key)
false Experimentation.in_experiment_group?(experiment_key, subject: subject)
end 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? 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) ::Gitlab::Tracking.event(tracking_data.delete(:category), tracking_data.delete(:action), **tracking_data)
end end
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? 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) gon.push(tracking_data: tracking_data)
end end
end end
def record_experiment_user(experiment_key) def record_experiment_user(experiment_key)
return if dnt_enabled? 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 end
def record_experiment_conversion_event(experiment_key) def record_experiment_conversion_event(experiment_key)
return if dnt_enabled? return if dnt_enabled?
return unless current_user return unless current_user
return unless Experimentation.enabled?(experiment_key) return unless Experimentation.active?(experiment_key)
::Experiment.record_conversion_event(experiment_key, current_user) ::Experiment.record_conversion_event(experiment_key, current_user)
end end
def experiment_tracking_category_and_group(experiment_key) def experiment_tracking_category_and_group(experiment_key, subject: nil)
"#{tracking_category(experiment_key)}:#{tracking_group(experiment_key, '_group')}" "#{tracking_category(experiment_key)}:#{tracking_group(experiment_key, '_group', subject: subject)}"
end end
private private
@ -89,40 +90,41 @@ module Gitlab
cookies.signed[:experimentation_subject_id] cookies.signed[:experimentation_subject_id]
end end
def experimentation_subject_index(experiment_key) def fallback_experimentation_subject_index(experiment_key)
return if experimentation_subject_id.blank? return if experimentation_subject_id.blank?
if Experimentation.experiment(experiment_key).use_backwards_compatible_subject_index if Experimentation.get_experiment(experiment_key).use_backwards_compatible_subject_index
experimentation_subject_id.delete('-').hex % 100 experimentation_subject_id.delete('-')
else else
Zlib.crc32("#{experiment_key}#{experimentation_subject_id}") % 100 experimentation_subject_id
end end
end end
def track_experiment_event_for(experiment_key, action, value) def track_experiment_event_for(experiment_key, action, value, subject: nil)
return unless Experimentation.enabled?(experiment_key) 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 end
def experimentation_tracking_data(experiment_key, action, value) def experimentation_tracking_data(experiment_key, action, value, subject: nil)
{ {
category: tracking_category(experiment_key), category: tracking_category(experiment_key),
action: action, action: action,
property: tracking_group(experiment_key, "_group"), property: tracking_group(experiment_key, "_group", subject: subject),
label: experimentation_subject_id, label: tracking_label(subject),
value: value value: value
}.compact }.compact
end end
def tracking_category(experiment_key) def tracking_category(experiment_key)
Experimentation.experiment(experiment_key).tracking_category Experimentation.get_experiment(experiment_key).tracking_category
end end
def tracking_group(experiment_key, suffix = nil) def tracking_group(experiment_key, suffix = nil, subject: nil)
return unless Experimentation.enabled?(experiment_key) 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 suffix ? "#{group}#{suffix}" : group
end end
@ -130,6 +132,16 @@ module Gitlab
def forced_enabled?(experiment_key) def forced_enabled?(experiment_key)
params.has_key?(:force_experiment) && params[:force_experiment] == experiment_key.to_s params.has_key?(:force_experiment) && params[:force_experiment] == experiment_key.to_s
end 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 end
end end

View File

@ -3,16 +3,17 @@
module Gitlab module Gitlab
module Experimentation module Experimentation
class Experiment 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) def initialize(key, **params)
@key = key
@tracking_category = params[:tracking_category] @tracking_category = params[:tracking_category]
@use_backwards_compatible_subject_index = params[:use_backwards_compatible_subject_index] @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 @experiment_percentage = Feature.get(:"#{key}_experiment_percentage").percentage_of_time_value # rubocop:disable Gitlab/AvoidFeatureGet
end end
def enabled? def active?
::Gitlab.dev_env_or_com? && experiment_percentage > 0 ::Gitlab.dev_env_or_com? && experiment_percentage > 0
end end

View File

@ -2119,6 +2119,9 @@ msgstr ""
msgid "AdminUsers|Blocking user has the following effects:" msgid "AdminUsers|Blocking user has the following effects:"
msgstr "" msgstr ""
msgid "AdminUsers|Cannot sign in or access instance information"
msgstr ""
msgid "AdminUsers|Cannot unblock LDAP blocked users" msgid "AdminUsers|Cannot unblock LDAP blocked users"
msgstr "" 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." 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 "" msgstr ""
msgid "AdminUsers|For more information, please refer to the %{link_start}user account deletion documentation.%{link_end}"
msgstr ""
msgid "AdminUsers|Is using seat" msgid "AdminUsers|Is using seat"
msgstr "" msgstr ""
@ -2191,6 +2197,15 @@ msgstr ""
msgid "AdminUsers|Regular users have access to their groups and projects" msgid "AdminUsers|Regular users have access to their groups and projects"
msgstr "" 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." msgid "AdminUsers|Restore user access to the account, including web, Git and API."
msgstr "" msgstr ""
@ -2248,6 +2263,9 @@ msgstr ""
msgid "AdminUsers|When the user logs back in, their account will reactivate as a fully active account" msgid "AdminUsers|When the user logs back in, their account will reactivate as a fully active account"
msgstr "" msgstr ""
msgid "AdminUsers|Will be deleted"
msgstr ""
msgid "AdminUsers|Without projects" msgid "AdminUsers|Without projects"
msgstr "" msgstr ""
@ -12773,6 +12791,9 @@ msgstr ""
msgid "GitLab Workhorse" msgid "GitLab Workhorse"
msgstr "" msgstr ""
msgid "GitLab account request rejected"
msgstr ""
msgid "GitLab commit" msgid "GitLab commit"
msgstr "" msgstr ""
@ -13811,6 +13832,9 @@ msgstr ""
msgid "HealthCheck|Unhealthy" msgid "HealthCheck|Unhealthy"
msgstr "" msgstr ""
msgid "Hello %{name},"
msgstr ""
msgid "Hello there" msgid "Hello there"
msgstr "" msgstr ""
@ -14790,6 +14814,9 @@ msgstr ""
msgid "Integrations|Standard" msgid "Integrations|Standard"
msgstr "" msgstr ""
msgid "Integrations|This integration, and inheriting projects were reset."
msgstr ""
msgid "Integrations|To keep this project going, create a new issue." msgid "Integrations|To keep this project going, create a new issue."
msgstr "" msgstr ""
@ -20317,6 +20344,9 @@ msgstr ""
msgid "Please complete your profile with email address" msgid "Please complete your profile with email address"
msgstr "" msgstr ""
msgid "Please contact your GitLab administrator if you think this is an error."
msgstr ""
msgid "Please contact your administrator with any questions." msgid "Please contact your administrator with any questions."
msgstr "" msgstr ""
@ -28110,6 +28140,9 @@ msgstr ""
msgid "This user cannot be unlocked manually from GitLab" msgid "This user cannot be unlocked manually from GitLab"
msgstr "" msgstr ""
msgid "This user does not have a pending request"
msgstr ""
msgid "This user has no active %{type}." msgid "This user has no active %{type}."
msgstr "" msgstr ""
@ -31045,6 +31078,9 @@ msgstr ""
msgid "You are not allowed to push into this branch. Create another branch or open a merge request." msgid "You are not allowed to push into this branch. Create another branch or open a merge request."
msgstr "" msgstr ""
msgid "You are not allowed to reject a user"
msgstr ""
msgid "You are not allowed to unlink your primary login account" msgid "You are not allowed to unlink your primary login account"
msgstr "" 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." 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 "" msgstr ""
msgid "You've rejected %{user}"
msgstr ""
msgid "YouTube" msgid "YouTube"
msgstr "" msgstr ""
@ -31747,6 +31786,9 @@ msgstr ""
msgid "Your request for access has been queued for review." msgid "Your request for access has been queued for review."
msgstr "" 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." msgid "Your requirements are being imported. Once finished, you'll receive a confirmation email."
msgstr "" msgstr ""

View File

@ -73,4 +73,20 @@ RSpec.describe Admin::IntegrationsController do
end end
end 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 end

View File

@ -102,6 +102,57 @@ RSpec.describe Admin::UsersController do
end end
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 describe 'PUT #approve' do
let(:user) { create(:user, :blocked_pending_approval) } let(:user) { create(:user, :blocked_pending_approval) }

View File

@ -333,7 +333,7 @@ RSpec.describe GroupsController, factory_default: :keep do
context 'and the user is part of the control group' do context 'and the user is part of the control group' do
before do before do
stub_experiment_for_user(onboarding_issues: false) stub_experiment_for_subject(onboarding_issues: false)
end end
it 'tracks the event with the "created_namespace" action with the "control_group" property', :snowplow do 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 context 'and the user is part of the experimental group' do
before do before do
stub_experiment_for_user(onboarding_issues: true) stub_experiment_for_subject(onboarding_issues: true)
end end
it 'tracks the event with the "created_namespace" action with the "experimental_group" property', :snowplow do it 'tracks the event with the "created_namespace" action with the "experimental_group" property', :snowplow do

View File

@ -25,7 +25,7 @@ RSpec.describe InvitesController, :snowplow do
shared_examples "tracks the 'accepted' event for the invitation reminders experiment" do shared_examples "tracks the 'accepted' event for the invitation reminders experiment" do
before do before do
stub_experiment(invitation_reminders: true) 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 end
context 'when in the control group' do context 'when in the control group' do

View File

@ -18,7 +18,7 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state do
describe 'pushing tracking_data to Gon' do describe 'pushing tracking_data to Gon' do
before do before do
stub_experiment(jobs_empty_state: experiment_active) 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 get_index
end end

View File

@ -19,7 +19,7 @@ RSpec.describe Registrations::ExperienceLevelsController do
context 'with an authenticated user' do context 'with an authenticated user' do
before do before do
sign_in(user) sign_in(user)
stub_experiment_for_user(onboarding_issues: true) stub_experiment_for_subject(onboarding_issues: true)
end end
it { is_expected.to have_gitlab_http_status(:ok) } 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 context 'when not part of the onboarding issues experiment' do
before do before do
stub_experiment_for_user(onboarding_issues: false) stub_experiment_for_subject(onboarding_issues: false)
end end
it { is_expected.to have_gitlab_http_status(:not_found) } 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 context 'with an authenticated user' do
before do before do
sign_in(user) sign_in(user)
stub_experiment_for_user(onboarding_issues: true) stub_experiment_for_subject(onboarding_issues: true)
end end
context 'when not part of the onboarding issues experiment' do context 'when not part of the onboarding issues experiment' do
before do before do
stub_experiment_for_user(onboarding_issues: false) stub_experiment_for_subject(onboarding_issues: false)
end end
it { is_expected.to have_gitlab_http_status(:not_found) } 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) } let(:issues_board) { build(:board, id: 123, project: project) }
before do before do
stub_experiment_for_user( stub_experiment_for_subject(
onboarding_issues: true, onboarding_issues: true,
default_to_issues_board: default_to_issues_board_xp? default_to_issues_board: default_to_issues_board_xp?
) )

View File

@ -125,7 +125,7 @@ RSpec.describe RootController do
context 'when experiment is enabled' do context 'when experiment is enabled' do
before do before do
stub_experiment_for_user(customize_homepage: true) stub_experiment_for_subject(customize_homepage: true)
end end
it 'renders the default dashboard' do it 'renders the default dashboard' do

View File

@ -52,7 +52,7 @@ FactoryBot.define do
after(:build) do |merge_request, evaluator| after(:build) do |merge_request, evaluator|
metrics = merge_request.build_metrics 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.merged_by = evaluator.merged_by
metrics.pipeline = create(:ci_empty_pipeline) metrics.pipeline = create(:ci_empty_pipeline)
end end

View File

@ -37,9 +37,7 @@ RSpec.describe 'Admin::Users::User' do
expect(page).to have_content(user.name) expect(page).to have_content(user.name)
expect(page).to have_content('Pending approval') expect(page).to have_content('Pending approval')
expect(page).to have_link('Approve user') expect(page).to have_link('Approve user')
expect(page).to have_button('Block user') expect(page).to have_link('Reject request')
expect(page).to have_button('Delete user')
expect(page).to have_button('Delete user and contributions')
end end
end end

View File

@ -28,7 +28,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
context 'with no jobs' do context 'with no jobs' do
before do before do
stub_experiment(jobs_empty_state: experiment_active) 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) visit project_jobs_path(project)
end end

View File

@ -9,7 +9,7 @@ RSpec.describe 'Experience level screen' do
before do before do
group.add_owner(user) group.add_owner(user)
gitlab_sign_in(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) visit users_sign_up_experience_level_path(namespace_path: group.to_param)
end end

View File

@ -1,13 +1,19 @@
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import createState from '~/integrations/edit/store/state'; import createState from '~/integrations/edit/store/state';
import { import {
setOverride, setOverride,
setIsSaving, setIsSaving,
setIsTesting, setIsTesting,
setIsResetting, setIsResetting,
requestResetIntegration,
receiveResetIntegrationSuccess,
receiveResetIntegrationError,
} from '~/integrations/edit/store/actions'; } from '~/integrations/edit/store/actions';
import * as types from '~/integrations/edit/store/mutation_types'; import * as types from '~/integrations/edit/store/mutation_types';
jest.mock('~/lib/utils/url_utility');
describe('Integration form store actions', () => { describe('Integration form store actions', () => {
let state; 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 },
]);
});
});
}); });

View File

@ -40,4 +40,20 @@ describe('Integration form store mutations', () => {
expect(state.isResetting).toBe(true); 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);
});
});
}); });

View File

@ -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);
});
});
});

View File

@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe GitlabSchema.types['MergeRequestConnection'] do RSpec.describe GitlabSchema.types['PipelineConnection'] do
it 'has the expected fields' do it 'has the expected fields' do
expected_fields = %i[count page_info edges nodes] expected_fields = %i[count page_info edges nodes]

View File

@ -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

View File

@ -59,4 +59,57 @@ RSpec.describe ServicesHelper do
end end
end 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 end

View File

@ -75,29 +75,24 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
describe '#push_frontend_experiment' do describe '#push_frontend_experiment' do
it 'pushes an experiment to the frontend' do it 'pushes an experiment to the frontend' do
gon = instance_double('gon') gon = instance_double('gon')
experiments = { experiments: { 'myExperiment' => true } } stub_experiment_for_subject(my_experiment: true)
stub_experiment_for_user(my_experiment: true)
allow(controller).to receive(:gon).and_return(gon) 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) controller.push_frontend_experiment(:my_experiment)
end end
end end
describe '#experiment_enabled?' do describe '#experiment_enabled?' do
def check_experiment(exp_key = :test_experiment) def check_experiment(exp_key = :test_experiment, subject = nil)
controller.experiment_enabled?(exp_key) controller.experiment_enabled?(exp_key, subject: subject)
end end
subject { check_experiment } subject { check_experiment }
context 'cookie is not present' do 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 it { is_expected.to eq(false) }
expect(Gitlab::Experimentation).to receive(:enabled_for_value?).with(:test_experiment, nil)
check_experiment
end
end end
context 'cookie is present' do context 'cookie is present' do
@ -109,37 +104,56 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
end end
where(:experiment_key, :index_value) do where(:experiment_key, :index_value) do
:test_experiment | 40 # Zlib.crc32('test_experimentabcd-1234') % 100 = 40 :test_experiment | 'abcd-1234'
:backwards_compatible_test_experiment | 76 # 'abcd1234'.hex % 100 = 76 :backwards_compatible_test_experiment | 'abcd1234'
end end
with_them do 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 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(:enabled_for_value?).with(experiment_key, index_value) expect(Gitlab::Experimentation).to receive(:in_experiment_group?).with(experiment_key, subject: index_value)
check_experiment(experiment_key) check_experiment(experiment_key)
end end
end end
context 'when subject is given' do
let(:user) { build(:user) }
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 end
it 'returns true when DNT: 0 is set in the request' do context 'do not track' do
allow(Gitlab::Experimentation).to receive(:enabled_for_value?) { true } before do
controller.request.headers['DNT'] = '0' allow(Gitlab::Experimentation).to receive(:in_experiment_group?) { true }
end
is_expected.to be_truthy 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 end
it 'returns false when DNT: 1 is set in the request' do context 'URL parameter to force enable experiment' do
allow(Gitlab::Experimentation).to receive(:enabled_for_value?) { true }
controller.request.headers['DNT'] = '1'
is_expected.to be_falsy
end
describe 'URL parameter to force enable experiment' do
it 'returns true unconditionally' do it 'returns true unconditionally' do
get :index, params: { force_experiment: :test_experiment } get :index, params: { force_experiment: :test_experiment }
is_expected.to be_truthy is_expected.to eq(true)
end end
end 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 context 'the user is part of the experimental group' do
before do before do
stub_experiment_for_user(test_experiment: true) stub_experiment_for_subject(test_experiment: true)
end end
it 'tracks the event with the right parameters' do 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 context 'the user is part of the control group' do
before do before do
stub_experiment_for_user(test_experiment: false) stub_experiment_for_subject(test_experiment: false)
end end
it 'tracks the event with the right parameters' do 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 expect_no_snowplow_event
end end
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 end
context 'when the experiment is disabled' do 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 context 'the user is part of the experimental group' do
before do before do
stub_experiment_for_user(test_experiment: true) stub_experiment_for_subject(test_experiment: true)
end end
it 'pushes the right parameters to gon' do 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 context 'the user is part of the control group' do
before do before do
allow_next_instance_of(described_class) do |instance| stub_experiment_for_subject(test_experiment: false)
allow(instance).to receive(:experiment_enabled?).with(:test_experiment).and_return(false)
end
end end
it 'pushes the right parameters to gon' do 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 it 'does not push data to gon' do
controller.frontend_experimentation_tracking_data(:test_experiment, 'start') 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 end
end end
@ -319,7 +384,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
end end
it 'does not push data to gon' do 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') controller.track_experiment_event(:test_experiment, 'start')
end end
end end
@ -336,7 +401,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
context 'the user is part of the experimental group' do context 'the user is part of the experimental group' do
before do before do
stub_experiment_for_user(test_experiment: true) stub_experiment_for_subject(test_experiment: true)
end end
it 'calls add_user on the Experiment model' do 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 context 'the user is part of the control group' do
before do before do
allow_next_instance_of(described_class) do |instance| stub_experiment_for_subject(test_experiment: false)
allow(instance).to receive(:experiment_enabled?).with(:test_experiment).and_return(false)
end
end end
it 'calls add_user on the Experiment model' do 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 context 'is disabled' do
before do before do
request.headers['DNT'] = '0' request.headers['DNT'] = '0'
stub_experiment_for_subject(test_experiment: false)
end end
it 'calls add_user on the Experiment model' do 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 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_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') expect(subject).to eq('Experiment::Category:experimental_group')
end end

View File

@ -20,14 +20,14 @@ RSpec.describe Gitlab::Experimentation::Experiment do
subject(:experiment) { described_class.new(:experiment_key, **params) } subject(:experiment) { described_class.new(:experiment_key, **params) }
describe '#enabled?' do describe '#active?' do
before do before do
allow(Gitlab).to receive(:dev_env_or_com?).and_return(on_gitlab_com) allow(Gitlab).to receive(:dev_env_or_com?).and_return(on_gitlab_com)
end 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 | 0 | false
true | 10 | true true | 10 | true
false | 0 | false false | 0 | false
@ -35,7 +35,7 @@ RSpec.describe Gitlab::Experimentation::Experiment do
end end
with_them do with_them do
it { is_expected.to eq(is_enabled) } it { is_expected.to eq(is_active) }
end end
end end

View File

@ -28,7 +28,7 @@ RSpec.describe Gitlab::Experimentation::EXPERIMENTS do
end end
end end
RSpec.describe Gitlab::Experimentation, :snowplow do RSpec.describe Gitlab::Experimentation do
before do before do
stub_const('Gitlab::Experimentation::EXPERIMENTS', { stub_const('Gitlab::Experimentation::EXPERIMENTS', {
backwards_compatible_test_experiment: { backwards_compatible_test_experiment: {
@ -47,92 +47,131 @@ RSpec.describe Gitlab::Experimentation, :snowplow do
let(:enabled_percentage) { 10 } let(:enabled_percentage) { 10 }
describe '.enabled?' do describe '.get_experiment' do
subject { described_class.enabled?(:test_experiment) } subject { described_class.get_experiment(:test_experiment) }
context 'feature toggle is enabled and we are selected' do context 'returns experiment' do
it { is_expected.to be_truthy } 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 end
describe 'experiment is not defined' do describe 'experiment is not defined' do
it 'returns false' 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
end end
describe 'experiment is disabled' do describe 'experiment is disabled' do
let(:enabled_percentage) { 0 } let(:enabled_percentage) { 0 }
it { is_expected.to be_falsey } it { is_expected.to eq(false) }
end end
end end
describe '.enabled_for_value?' do describe '.in_experiment_group?' do
subject { described_class.enabled_for_value?(:test_experiment, experimentation_subject_index) } 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 } subject { described_class.in_experiment_group?(:test_experiment, subject: experiment_subject) }
context 'experiment is disabled' do context 'when experiment is active' do
before do context 'when subject is part of the experiment' do
allow(described_class).to receive(:enabled?).and_return(false) it { is_expected.to eq(true) }
end
it { is_expected.to be_falsey }
end
context 'experiment is enabled' do
before do
allow(described_class).to receive(:enabled?).and_return(true)
end
it { is_expected.to be_truthy }
describe 'experimentation_subject_index' do
context 'experimentation_subject_index is not set' do
let(:experimentation_subject_index) { nil }
it { is_expected.to be_falsey }
end end
context 'experimentation_subject_index is an empty string' do context 'when subject is not part of the experiment' do
let(:experimentation_subject_index) { '' } let(:experiment_subject) { 'a' } # Zlib.crc32('test_experimenta') % 100 = 61
it { is_expected.to be_falsey } it { is_expected.to eq(false) }
end end
context 'experimentation_subject_index outside enabled ratio' do context 'when subject has a global_id' do
let(:experimentation_subject_index) { 11 } let(:experiment_subject) { double(:subject, to_global_id: 'z') }
it { is_expected.to be_falsey } it { is_expected.to eq(true) }
end
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
end end
end
end
describe '.enabled_for_attribute?' do context 'when experiment is not active' do
subject { described_class.enabled_for_attribute?(:test_experiment, attribute) } before do
allow(described_class).to receive(:active?).and_return(false)
end
let(:attribute) { 'abcd' } # Digest::SHA1.hexdigest('abcd').hex % 100 = 7 it { is_expected.to eq(false) }
context 'experiment is disabled' do
before do
allow(described_class).to receive(:enabled?).and_return(false)
end end
it { is_expected.to be false }
end end
context 'experiment is enabled' do context 'with backwards compatible index calculation' do
before do let(:experiment_subject) { 'abcd' } # Digest::SHA1.hexdigest('abcd').hex % 100 = 7
allow(described_class).to receive(:enabled?).and_return(true)
subject { described_class.in_experiment_group?(:backwards_compatible_test_experiment, subject: experiment_subject) }
context 'when experiment is active' do
before do
allow(described_class).to receive(:active?).and_return(true)
end
context 'when subject is part of the experiment' do
it { is_expected.to eq(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 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 '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 end
it { is_expected.to be true } context 'when experiment is not active' do
before do
allow(described_class).to receive(:active?).and_return(false)
end
context 'outside enabled ratio' do it { is_expected.to eq(false) }
let(:attribute) { 'abc' } # Digest::SHA1.hexdigest('abc').hex % 100 = 17
it { is_expected.to be false }
end end
end end
end end

View File

@ -1453,7 +1453,7 @@ RSpec.describe Notify do
shared_examples "tracks the 'sent' event for the invitation reminders experiment" do shared_examples "tracks the 'sent' event for the invitation reminders experiment" do
before do before do
stub_experiment(invitation_reminders: true) 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 end
it "tracks the 'sent' event", :snowplow do it "tracks the 'sent' event", :snowplow do

View File

@ -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

View File

@ -500,6 +500,77 @@ RSpec.describe MergeRequest, factory_default: :keep do
end end
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 describe '#target_branch_sha' do
let(:project) { create(:project, :repository) } let(:project) { create(:project, :repository) }

View File

@ -150,6 +150,24 @@ RSpec.describe GlobalPolicy do
end end
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 describe 'using project statistics filters' do
context 'regular user' do context 'regular user' do
it { is_expected.not_to be_allowed(:use_project_statistics_filters) } it { is_expected.not_to be_allowed(:use_project_statistics_filters) }

View File

@ -11,7 +11,7 @@ RSpec.describe Members::InvitationReminderEmailService do
context 'when the experiment is disabled' do context 'when the experiment is disabled' do
before 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 invitation.expires_at = frozen_time + 2.days
end end
@ -26,7 +26,7 @@ RSpec.describe Members::InvitationReminderEmailService do
context 'when the experiment is enabled' do context 'when the experiment is enabled' do
before 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 invitation.expires_at = frozen_time + expires_at_days.days if expires_at_days
end end

View File

@ -2326,6 +2326,20 @@ RSpec.describe NotificationService, :mailer do
end end
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 describe 'GroupMember', :deliver_mails_inline do
let(:added_user) { create(:user) } let(:added_user) { create(:user) }

View File

@ -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

View File

@ -3,15 +3,15 @@
module StubExperiments module StubExperiments
# Stub Experiment with `key: true/false` # 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 # Examples
# - `stub_experiment(signup_flow: false)` ... Disable `signup_flow` experiment globally. # - `stub_experiment(signup_flow: false)` ... Disables `signup_flow` experiment.
def stub_experiment(experiments) 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| 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
end end
@ -20,12 +20,12 @@ module StubExperiments
# @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 enabled or not.
# #
# Examples # Examples
# - `stub_experiment_for_user(signup_flow: false)` ... Disable `signup_flow` experiment for user. # - `stub_experiment_for_subject(signup_flow: false)` ... Disable `signup_flow` experiment for user.
def stub_experiment_for_user(experiments) def stub_experiment_for_subject(experiments)
allow(Gitlab::Experimentation).to receive(:enabled_for_value?).and_call_original allow(Gitlab::Experimentation).to receive(:in_experiment_group?).and_call_original
experiments.each do |experiment_key, enabled| 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 end
end end

View File

@ -3,7 +3,7 @@
RSpec.shared_examples 'issuable invite members experiments' do RSpec.shared_examples 'issuable invite members experiments' do
context 'when invite_members_version_a experiment is enabled' do context 'when invite_members_version_a experiment is enabled' do
before do before do
stub_experiment_for_user(invite_members_version_a: true) stub_experiment_for_subject(invite_members_version_a: true)
end end
it 'shows a link for inviting members and follows through to the members page' do 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 context 'when invite_members_version_b experiment is enabled' do
before do before do
stub_experiment_for_user(invite_members_version_b: true) stub_experiment_for_subject(invite_members_version_b: true)
end end
it 'shows a link for inviting members and follows through to modal' do it 'shows a link for inviting members and follows through to modal' do