Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
		
							parent
							
								
									f96f2720d1
								
							
						
					
					
						commit
						1502c20d04
					
				|  | @ -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> | ||||||
|  |  | ||||||
|  | @ -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')); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | @ -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'; | ||||||
|  |  | ||||||
|  | @ -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; | ||||||
|  |   }, | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | @ -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)) : [], | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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 }; | ||||||
|  | @ -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? | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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? | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  | @ -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) | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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) | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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) | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  | @ -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?') } | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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 } | ||||||
|  | @ -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' | ||||||
|  |  | ||||||
|  | @ -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.') | ||||||
|  | @ -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.') %> | ||||||
|  | 
 | ||||||
|  | @ -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) } | ||||||
|  |  | ||||||
|  | @ -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| | ||||||
|  |  | ||||||
|  | @ -0,0 +1,6 @@ | ||||||
|  | --- | ||||||
|  | title: Add index for the `vulnerabilities` table on `project_id`, `state`, and `severity` | ||||||
|  |   columns | ||||||
|  | merge_request: 48930 | ||||||
|  | author: | ||||||
|  | type: added | ||||||
|  | @ -0,0 +1,5 @@ | ||||||
|  | --- | ||||||
|  | title: Add migration to populate remaining dismissal information for vulnerabilities | ||||||
|  | merge_request: 48472 | ||||||
|  | author: | ||||||
|  | type: added | ||||||
|  | @ -0,0 +1,5 @@ | ||||||
|  | --- | ||||||
|  | title: Email user when registration request is rejected | ||||||
|  | merge_request: 48185 | ||||||
|  | author: | ||||||
|  | type: added | ||||||
|  | @ -0,0 +1,5 @@ | ||||||
|  | --- | ||||||
|  | title: Add merge requests total time to merge field to the GraphQL API | ||||||
|  | merge_request: 46040 | ||||||
|  | author: | ||||||
|  | type: added | ||||||
|  | @ -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 | ||||||
|  | @ -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 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  | @ -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 | ||||||
|  | @ -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 | ||||||
|  | @ -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 | ||||||
|  | @ -0,0 +1 @@ | ||||||
|  | bde71afbe34006eedbd97ac457df31b247fc89a572ca8900c60b16c4d6a8ef93 | ||||||
|  | @ -0,0 +1 @@ | ||||||
|  | 1113642dfc9069dcea01ac12b1653dfcf67b3aea449538e9747d3bc857ce88d8 | ||||||
|  | @ -0,0 +1 @@ | ||||||
|  | 54ed18361a28d0b750cbbdb3bfb53b7e4bbe3d1d7264de51522796d3bd15f7a5 | ||||||
|  | @ -0,0 +1 @@ | ||||||
|  | cb11dc9996b1706feaa8a53f96cbaa6209a4d07b3be9e88ebc3d1e1ada561287 | ||||||
|  | @ -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); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| """ | """ | ||||||
|  |  | ||||||
|  | @ -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, | ||||||
|  |  | ||||||
|  | @ -73,7 +73,7 @@ to your needs: | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
| 
 | 
 | ||||||
| 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. | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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. | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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). | ||||||
|  | @ -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/) | ||||||
|  |  | ||||||
|  | @ -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 } | ||||||
|  |  | ||||||
|  | @ -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` | ||||||
|  |  | ||||||
|  | @ -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. | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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**: | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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 | | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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. | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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 "" | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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) } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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? | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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 }, | ||||||
|  |       ]); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | @ -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); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | @ -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); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  | @ -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] | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  | @ -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) } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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) } | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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) } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue