Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
		
							parent
							
								
									a27c21b5af
								
							
						
					
					
						commit
						3a8d221b7e
					
				|  | @ -456,7 +456,6 @@ Lint/UnusedMethodArgument: | |||
|     - 'lib/gitlab/diff/file_collection/merge_request_diff_base.rb' | ||||
|     - 'lib/gitlab/diff/line.rb' | ||||
|     - 'lib/gitlab/encoding_helper.rb' | ||||
|     - 'lib/gitlab/error_tracking/error_repository/active_record_strategy.rb' | ||||
|     - 'lib/gitlab/fogbugz_import/importer.rb' | ||||
|     - 'lib/gitlab/git/commit_stats.rb' | ||||
|     - 'lib/gitlab/git/conflict/parser.rb' | ||||
|  |  | |||
|  | @ -79,7 +79,6 @@ Naming/InclusiveLanguage: | |||
|     - 'spec/rubocop/cop/graphql/id_type_spec.rb' | ||||
|     - 'spec/services/application_settings/update_service_spec.rb' | ||||
|     - 'spec/services/design_management/generate_image_versions_service_spec.rb' | ||||
|     - 'spec/services/error_tracking/list_issues_service_spec.rb' | ||||
|     - 'spec/services/projects/download_service_spec.rb' | ||||
|     - 'spec/support/import_export/export_file_helper.rb' | ||||
|     - 'spec/support/shared_contexts/upload_type_check_shared_context.rb' | ||||
|  |  | |||
|  | @ -2670,7 +2670,6 @@ RSpec/ContextWording: | |||
|     - 'spec/services/draft_notes/publish_service_spec.rb' | ||||
|     - 'spec/services/environments/schedule_to_delete_review_apps_service_spec.rb' | ||||
|     - 'spec/services/environments/stop_service_spec.rb' | ||||
|     - 'spec/services/error_tracking/list_issues_service_spec.rb' | ||||
|     - 'spec/services/error_tracking/list_projects_service_spec.rb' | ||||
|     - 'spec/services/events/destroy_service_spec.rb' | ||||
|     - 'spec/services/feature_flags/update_service_spec.rb' | ||||
|  |  | |||
|  | @ -1,142 +0,0 @@ | |||
| /* eslint-disable no-restricted-globals */ | ||||
| 
 | ||||
| import { logger } from '@rails/actioncable'; | ||||
| 
 | ||||
| // This is based on https://github.com/rails/rails/blob/5a477890c809d4a17dc0dede43c6b8cef81d8175/actioncable/app/javascript/action_cable/connection_monitor.js
 | ||||
| // so that we can take advantage of the improved reconnection logic. We can remove this once we upgrade @rails/actioncable to a version that includes this.
 | ||||
| 
 | ||||
| // Responsible for ensuring the cable connection is in good health by validating the heartbeat pings sent from the server, and attempting
 | ||||
| // revival reconnections if things go astray. Internal class, not intended for direct user manipulation.
 | ||||
| 
 | ||||
| const now = () => new Date().getTime(); | ||||
| 
 | ||||
| const secondsSince = (time) => (now() - time) / 1000; | ||||
| class ConnectionMonitor { | ||||
|   constructor(connection) { | ||||
|     this.visibilityDidChange = this.visibilityDidChange.bind(this); | ||||
|     this.connection = connection; | ||||
|     this.reconnectAttempts = 0; | ||||
|   } | ||||
| 
 | ||||
|   start() { | ||||
|     if (!this.isRunning()) { | ||||
|       this.startedAt = now(); | ||||
|       delete this.stoppedAt; | ||||
|       this.startPolling(); | ||||
|       addEventListener('visibilitychange', this.visibilityDidChange); | ||||
|       logger.log( | ||||
|         `ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`, | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   stop() { | ||||
|     if (this.isRunning()) { | ||||
|       this.stoppedAt = now(); | ||||
|       this.stopPolling(); | ||||
|       removeEventListener('visibilitychange', this.visibilityDidChange); | ||||
|       logger.log('ConnectionMonitor stopped'); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   isRunning() { | ||||
|     return this.startedAt && !this.stoppedAt; | ||||
|   } | ||||
| 
 | ||||
|   recordPing() { | ||||
|     this.pingedAt = now(); | ||||
|   } | ||||
| 
 | ||||
|   recordConnect() { | ||||
|     this.reconnectAttempts = 0; | ||||
|     this.recordPing(); | ||||
|     delete this.disconnectedAt; | ||||
|     logger.log('ConnectionMonitor recorded connect'); | ||||
|   } | ||||
| 
 | ||||
|   recordDisconnect() { | ||||
|     this.disconnectedAt = now(); | ||||
|     logger.log('ConnectionMonitor recorded disconnect'); | ||||
|   } | ||||
| 
 | ||||
|   // Private
 | ||||
| 
 | ||||
|   startPolling() { | ||||
|     this.stopPolling(); | ||||
|     this.poll(); | ||||
|   } | ||||
| 
 | ||||
|   stopPolling() { | ||||
|     clearTimeout(this.pollTimeout); | ||||
|   } | ||||
| 
 | ||||
|   poll() { | ||||
|     this.pollTimeout = setTimeout(() => { | ||||
|       this.reconnectIfStale(); | ||||
|       this.poll(); | ||||
|     }, this.getPollInterval()); | ||||
|   } | ||||
| 
 | ||||
|   getPollInterval() { | ||||
|     const { staleThreshold, reconnectionBackoffRate } = this.constructor; | ||||
|     const backoff = (1 + reconnectionBackoffRate) ** Math.min(this.reconnectAttempts, 10); | ||||
|     const jitterMax = this.reconnectAttempts === 0 ? 1.0 : reconnectionBackoffRate; | ||||
|     const jitter = jitterMax * Math.random(); | ||||
|     return staleThreshold * 1000 * backoff * (1 + jitter); | ||||
|   } | ||||
| 
 | ||||
|   reconnectIfStale() { | ||||
|     if (this.connectionIsStale()) { | ||||
|       logger.log( | ||||
|         `ConnectionMonitor detected stale connection. reconnectAttempts = ${ | ||||
|           this.reconnectAttempts | ||||
|         }, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${ | ||||
|           this.constructor.staleThreshold | ||||
|         } s`,
 | ||||
|       ); | ||||
|       this.reconnectAttempts += 1; | ||||
|       if (this.disconnectedRecently()) { | ||||
|         logger.log( | ||||
|           `ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince( | ||||
|             this.disconnectedAt, | ||||
|           )} s`,
 | ||||
|         ); | ||||
|       } else { | ||||
|         logger.log('ConnectionMonitor reopening'); | ||||
|         this.connection.reopen(); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   get refreshedAt() { | ||||
|     return this.pingedAt ? this.pingedAt : this.startedAt; | ||||
|   } | ||||
| 
 | ||||
|   connectionIsStale() { | ||||
|     return secondsSince(this.refreshedAt) > this.constructor.staleThreshold; | ||||
|   } | ||||
| 
 | ||||
|   disconnectedRecently() { | ||||
|     return ( | ||||
|       this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   visibilityDidChange() { | ||||
|     if (document.visibilityState === 'visible') { | ||||
|       setTimeout(() => { | ||||
|         if (this.connectionIsStale() || !this.connection.isOpen()) { | ||||
|           logger.log( | ||||
|             `ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`, | ||||
|           ); | ||||
|           this.connection.reopen(); | ||||
|         } | ||||
|       }, 200); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| ConnectionMonitor.staleThreshold = 6; // Server::Connections::BEAT_INTERVAL * 2 (missed two pings)
 | ||||
| ConnectionMonitor.reconnectionBackoffRate = 0.15; | ||||
| 
 | ||||
| export default ConnectionMonitor; | ||||
|  | @ -1,10 +1,3 @@ | |||
| import { createConsumer } from '@rails/actioncable'; | ||||
| import ConnectionMonitor from './actioncable_connection_monitor'; | ||||
| 
 | ||||
| const consumer = createConsumer(); | ||||
| 
 | ||||
| if (consumer.connection) { | ||||
|   consumer.connection.monitor = new ConnectionMonitor(consumer.connection); | ||||
| } | ||||
| 
 | ||||
| export default consumer; | ||||
| export default createConsumer(); | ||||
|  |  | |||
|  | @ -126,10 +126,6 @@ export default { | |||
|     isSearchFiltered() { | ||||
|       return isSearchFiltered(this.search); | ||||
|     }, | ||||
|     shouldShowCreateRunnerWorkflow() { | ||||
|       // create_runner_workflow_for_admin feature flag | ||||
|       return this.glFeatures.createRunnerWorkflowForAdmin; | ||||
|     }, | ||||
|   }, | ||||
|   watch: { | ||||
|     search: { | ||||
|  | @ -193,7 +189,7 @@ export default { | |||
|       /> | ||||
| 
 | ||||
|       <div class="gl-w-full gl-md-w-auto gl-display-flex"> | ||||
|         <gl-button v-if="shouldShowCreateRunnerWorkflow" :href="newRunnerPath" variant="confirm"> | ||||
|         <gl-button :href="newRunnerPath" variant="confirm"> | ||||
|           {{ s__('Runners|New instance runner') }} | ||||
|         </gl-button> | ||||
|         <registration-dropdown | ||||
|  |  | |||
|  | @ -20,6 +20,9 @@ export default { | |||
|     showInstallationInstructions: s__( | ||||
|       'Runners|Show runner installation and registration instructions', | ||||
|     ), | ||||
|     supportForRegistrationTokensDeprecated: s__( | ||||
|       'Runners|Support for registration tokens is deprecated', | ||||
|     ), | ||||
|   }, | ||||
|   components: { | ||||
|     GlDropdown, | ||||
|  | @ -51,14 +54,6 @@ export default { | |||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     isDeprecated() { | ||||
|       // Show a compact version when used as secondary option | ||||
|       // create_runner_workflow_for_admin or create_runner_workflow_for_namespace | ||||
|       return ( | ||||
|         this.glFeatures?.createRunnerWorkflowForAdmin || | ||||
|         this.glFeatures?.createRunnerWorkflowForNamespace | ||||
|       ); | ||||
|     }, | ||||
|     actionText() { | ||||
|       switch (this.type) { | ||||
|         case INSTANCE_TYPE: | ||||
|  | @ -71,30 +66,6 @@ export default { | |||
|           return I18N_REGISTER_RUNNER; | ||||
|       } | ||||
|     }, | ||||
|     dropdownText() { | ||||
|       if (this.isDeprecated) { | ||||
|         return ''; | ||||
|       } | ||||
|       return this.actionText; | ||||
|     }, | ||||
|     dropdownToggleClass() { | ||||
|       if (this.isDeprecated) { | ||||
|         return ['gl-px-3!']; | ||||
|       } | ||||
|       return []; | ||||
|     }, | ||||
|     dropdownCategory() { | ||||
|       if (this.isDeprecated) { | ||||
|         return 'tertiary'; | ||||
|       } | ||||
|       return 'primary'; | ||||
|     }, | ||||
|     dropdownVariant() { | ||||
|       if (this.isDeprecated) { | ||||
|         return 'default'; | ||||
|       } | ||||
|       return 'confirm'; | ||||
|     }, | ||||
|   }, | ||||
|   methods: { | ||||
|     onShowInstructionsClick() { | ||||
|  | @ -113,22 +84,21 @@ export default { | |||
|   <gl-dropdown | ||||
|     ref="runnerRegistrationDropdown" | ||||
|     menu-class="gl-w-auto!" | ||||
|     :text="dropdownText" | ||||
|     :toggle-class="dropdownToggleClass" | ||||
|     :variant="dropdownVariant" | ||||
|     :category="dropdownCategory" | ||||
|     toggle-class="gl-px-3!" | ||||
|     variant="default" | ||||
|     category="tertiary" | ||||
|     v-bind="$attrs" | ||||
|   > | ||||
|     <template v-if="isDeprecated" #button-content> | ||||
|     <template #button-content> | ||||
|       <span class="gl-sr-only">{{ actionText }}</span> | ||||
|       <gl-icon name="ellipsis_v" /> | ||||
|     </template> | ||||
|     <gl-dropdown-form class="gl-p-4!"> | ||||
|       <registration-token input-id="token-value" :value="currentRegistrationToken"> | ||||
|         <template v-if="isDeprecated" #label-description> | ||||
|         <template #label-description> | ||||
|           <gl-icon name="warning" class="gl-text-orange-500" /> | ||||
|           <span class="gl-text-secondary"> | ||||
|             {{ s__('Runners|Support for registration tokens is deprecated') }} | ||||
|             {{ $options.i18n.supportForRegistrationTokensDeprecated }} | ||||
|           </span> | ||||
|         </template> | ||||
|       </registration-token> | ||||
|  |  | |||
|  | @ -11,7 +11,6 @@ import { | |||
|   I18N_CREATE_RUNNER_LINK, | ||||
|   I18N_STILL_USING_REGISTRATION_TOKENS, | ||||
|   I18N_CONTACT_ADMIN_TO_REGISTER, | ||||
|   I18N_FOLLOW_REGISTRATION_INSTRUCTIONS, | ||||
|   I18N_NO_RESULTS, | ||||
|   I18N_EDIT_YOUR_SEARCH, | ||||
| } from '~/ci/runner/constants'; | ||||
|  | @ -44,15 +43,6 @@ export default { | |||
|       default: null, | ||||
|     }, | ||||
|   }, | ||||
|   computed: { | ||||
|     shouldShowCreateRunnerWorkflow() { | ||||
|       // create_runner_workflow_for_admin or create_runner_workflow_for_namespace | ||||
|       return ( | ||||
|         this.glFeatures?.createRunnerWorkflowForAdmin || | ||||
|         this.glFeatures?.createRunnerWorkflowForNamespace | ||||
|       ); | ||||
|     }, | ||||
|   }, | ||||
|   modalId: 'runners-empty-state-instructions-modal', | ||||
|   svgHeight: 145, | ||||
|   EMPTY_STATE_SVG_URL, | ||||
|  | @ -63,7 +53,6 @@ export default { | |||
|   I18N_CREATE_RUNNER_LINK, | ||||
|   I18N_STILL_USING_REGISTRATION_TOKENS, | ||||
|   I18N_CONTACT_ADMIN_TO_REGISTER, | ||||
|   I18N_FOLLOW_REGISTRATION_INSTRUCTIONS, | ||||
|   I18N_NO_RESULTS, | ||||
|   I18N_EDIT_YOUR_SEARCH, | ||||
| }; | ||||
|  | @ -85,39 +74,22 @@ export default { | |||
|   > | ||||
|     <template #description> | ||||
|       {{ $options.I18N_RUNNERS_ARE_AGENTS }} | ||||
|       <template v-if="shouldShowCreateRunnerWorkflow"> | ||||
|         <gl-sprintf v-if="newRunnerPath" :message="$options.I18N_CREATE_RUNNER_LINK"> | ||||
|           <template #link="{ content }"> | ||||
|             <gl-link :href="newRunnerPath">{{ content }}</gl-link> | ||||
|           </template> | ||||
|         </gl-sprintf> | ||||
|         <template v-if="registrationToken"> | ||||
|           <br /> | ||||
|           <gl-link v-gl-modal="$options.modalId">{{ | ||||
|             $options.I18N_STILL_USING_REGISTRATION_TOKENS | ||||
|           }}</gl-link> | ||||
|           <runner-instructions-modal | ||||
|             :modal-id="$options.modalId" | ||||
|             :registration-token="registrationToken" | ||||
|           /> | ||||
|         </template> | ||||
|         <template v-if="!newRunnerPath && !registrationToken"> | ||||
|           {{ $options.I18N_CONTACT_ADMIN_TO_REGISTER }} | ||||
|         </template> | ||||
|       </template> | ||||
|       <gl-sprintf | ||||
|         v-else-if="registrationToken" | ||||
|         :message="$options.I18N_FOLLOW_REGISTRATION_INSTRUCTIONS" | ||||
|       > | ||||
|       <gl-sprintf v-if="newRunnerPath" :message="$options.I18N_CREATE_RUNNER_LINK"> | ||||
|         <template #link="{ content }"> | ||||
|           <gl-link v-gl-modal="$options.modalId">{{ content }}</gl-link> | ||||
|           <runner-instructions-modal | ||||
|             :modal-id="$options.modalId" | ||||
|             :registration-token="registrationToken" | ||||
|           /> | ||||
|           <gl-link :href="newRunnerPath">{{ content }}</gl-link> | ||||
|         </template> | ||||
|       </gl-sprintf> | ||||
|       <template v-else> | ||||
|       <template v-if="registrationToken"> | ||||
|         <br /> | ||||
|         <gl-link v-gl-modal="$options.modalId">{{ | ||||
|           $options.I18N_STILL_USING_REGISTRATION_TOKENS | ||||
|         }}</gl-link> | ||||
|         <runner-instructions-modal | ||||
|           :modal-id="$options.modalId" | ||||
|           :registration-token="registrationToken" | ||||
|         /> | ||||
|       </template> | ||||
|       <template v-if="!newRunnerPath && !registrationToken"> | ||||
|         {{ $options.I18N_CONTACT_ADMIN_TO_REGISTER }} | ||||
|       </template> | ||||
|     </template> | ||||
|  |  | |||
|  | @ -117,9 +117,6 @@ export const I18N_STILL_USING_REGISTRATION_TOKENS = s__('Runners|Still using reg | |||
| export const I18N_CONTACT_ADMIN_TO_REGISTER = s__( | ||||
|   'Runners|To register new runners, contact your administrator.', | ||||
| ); | ||||
| export const I18N_FOLLOW_REGISTRATION_INSTRUCTIONS = s__( | ||||
|   'Runners|Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.', | ||||
| ); | ||||
| 
 | ||||
| // No runners found
 | ||||
| export const I18N_NO_RESULTS = s__('Runners|No results found'); | ||||
|  |  | |||
|  | @ -155,10 +155,6 @@ export default { | |||
|     isSearchFiltered() { | ||||
|       return isSearchFiltered(this.search); | ||||
|     }, | ||||
|     shouldShowCreateRunnerWorkflow() { | ||||
|       // create_runner_workflow_for_namespace feature flag | ||||
|       return this.glFeatures.createRunnerWorkflowForNamespace; | ||||
|     }, | ||||
|   }, | ||||
|   watch: { | ||||
|     search: { | ||||
|  | @ -231,11 +227,7 @@ export default { | |||
|       /> | ||||
| 
 | ||||
|       <div class="gl-w-full gl-md-w-auto gl-display-flex"> | ||||
|         <gl-button | ||||
|           v-if="shouldShowCreateRunnerWorkflow && newRunnerPath" | ||||
|           :href="newRunnerPath" | ||||
|           variant="confirm" | ||||
|         > | ||||
|         <gl-button v-if="newRunnerPath" :href="newRunnerPath" variant="confirm"> | ||||
|           {{ s__('Runners|New group runner') }} | ||||
|         </gl-button> | ||||
|         <registration-dropdown | ||||
|  |  | |||
|  | @ -19,12 +19,12 @@ export default { | |||
|   locked: { | ||||
|     icon: 'lock', | ||||
|     class: 'value', | ||||
|     iconClass: 'is-active', | ||||
|     displayText: __('Locked'), | ||||
|   }, | ||||
|   unlocked: { | ||||
|     class: ['no-value hide-collapsed'], | ||||
|     icon: 'lock-open', | ||||
|     iconClass: '', | ||||
|     displayText: __('Unlocked'), | ||||
|   }, | ||||
|   components: { | ||||
|     EditForm, | ||||
|  | @ -49,8 +49,6 @@ export default { | |||
|     issueCapitalized: __('Issue'), | ||||
|     mergeRequest: __('merge request'), | ||||
|     mergeRequestCapitalized: __('Merge request'), | ||||
|     locked: __('Locked'), | ||||
|     unlocked: __('Unlocked'), | ||||
|     lockingMergeRequest: __('Locking %{issuableDisplayName}'), | ||||
|     unlockingMergeRequest: __('Unlocking %{issuableDisplayName}'), | ||||
|     lockMergeRequest: __('Lock %{issuableDisplayName}'), | ||||
|  | @ -84,10 +82,7 @@ export default { | |||
|       return this.getNoteableData.discussion_locked; | ||||
|     }, | ||||
|     lockStatus() { | ||||
|       return this.isLocked ? this.$options.i18n.locked : this.$options.i18n.unlocked; | ||||
|     }, | ||||
|     tooltipLabel() { | ||||
|       return this.isLocked ? this.$options.i18n.locked : this.$options.i18n.unlocked; | ||||
|       return this.isLocked ? this.$options.locked : this.$options.unlocked; | ||||
|     }, | ||||
|     lockToggleInProgressText() { | ||||
|       return this.isLocked ? this.unlockingMergeRequestText : this.lockingMergeRequestText; | ||||
|  | @ -205,7 +200,7 @@ export default { | |||
|   </gl-disclosure-dropdown-item> | ||||
|   <div v-else class="block issuable-sidebar-item lock"> | ||||
|     <div | ||||
|       v-gl-tooltip.left.viewport="{ title: tooltipLabel }" | ||||
|       v-gl-tooltip.left.viewport="{ title: lockStatus.displayText }" | ||||
|       class="sidebar-collapsed-icon" | ||||
|       data-testid="sidebar-collapse-icon" | ||||
|       @click="toggleForm" | ||||
|  | @ -239,7 +234,7 @@ export default { | |||
|       /> | ||||
| 
 | ||||
|       <div data-testid="lock-status" class="sidebar-item-value" :class="lockStatus.class"> | ||||
|         {{ lockStatus }} | ||||
|         {{ lockStatus.displayText }} | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
|  |  | |||
|  | @ -122,14 +122,6 @@ export default { | |||
|           return null; | ||||
|       } | ||||
|     }, | ||||
|     showDeprecationAlert() { | ||||
|       return ( | ||||
|         // create_runner_workflow_for_admin | ||||
|         this.glFeatures.createRunnerWorkflowForAdmin || | ||||
|         // create_runner_workflow_for_namespace | ||||
|         this.glFeatures.createRunnerWorkflowForNamespace | ||||
|       ); | ||||
|     }, | ||||
|   }, | ||||
|   updated() { | ||||
|     // Refocus on dom changes, after loading data | ||||
|  | @ -200,12 +192,7 @@ export default { | |||
|     v-on="$listeners" | ||||
|     @shown="onShown" | ||||
|   > | ||||
|     <gl-alert | ||||
|       v-if="showDeprecationAlert" | ||||
|       :title="$options.i18n.deprecationAlertTitle" | ||||
|       variant="warning" | ||||
|       :dismissible="false" | ||||
|     > | ||||
|     <gl-alert :title="$options.i18n.deprecationAlertTitle" variant="warning" :dismissible="false"> | ||||
|       <gl-sprintf :message="$options.i18n.deprecationAlertContent"> | ||||
|         <template #link="{ content }"> | ||||
|           <gl-link target="_blank" :href="$options.LEGACY_REGISTER_HELP_URL" | ||||
|  |  | |||
|  | @ -5,10 +5,6 @@ class Admin::RunnersController < Admin::ApplicationController | |||
| 
 | ||||
|   before_action :runner, except: [:index, :new, :tag_list, :runner_setup_scripts] | ||||
| 
 | ||||
|   before_action only: [:index] do | ||||
|     push_frontend_feature_flag(:create_runner_workflow_for_admin, current_user) | ||||
|   end | ||||
| 
 | ||||
|   feature_category :runner | ||||
|   urgency :low | ||||
| 
 | ||||
|  | @ -23,12 +19,10 @@ class Admin::RunnersController < Admin::ApplicationController | |||
|   end | ||||
| 
 | ||||
|   def new | ||||
|     render_404 unless Feature.enabled?(:create_runner_workflow_for_admin, current_user) | ||||
|   end | ||||
| 
 | ||||
|   def register | ||||
|     render_404 unless Feature.enabled?(:create_runner_workflow_for_admin, current_user) && | ||||
|       runner.registration_available? | ||||
|     render_404 unless runner.registration_available? | ||||
|   end | ||||
| 
 | ||||
|   def update | ||||
|  |  | |||
|  | @ -6,10 +6,6 @@ class Groups::RunnersController < Groups::ApplicationController | |||
|   before_action :authorize_update_runner!, only: [:edit, :update, :destroy, :pause, :resume] | ||||
|   before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show, :register] | ||||
| 
 | ||||
|   before_action only: [:index] do | ||||
|     push_frontend_feature_flag(:create_runner_workflow_for_namespace, group) | ||||
|   end | ||||
| 
 | ||||
|   feature_category :runner | ||||
|   urgency :low | ||||
| 
 | ||||
|  | @ -35,11 +31,10 @@ class Groups::RunnersController < Groups::ApplicationController | |||
|   end | ||||
| 
 | ||||
|   def new | ||||
|     render_404 unless create_runner_workflow_for_namespace_enabled? | ||||
|   end | ||||
| 
 | ||||
|   def register | ||||
|     render_404 unless create_runner_workflow_for_namespace_enabled? && runner.registration_available? | ||||
|     render_404 unless runner.registration_available? | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
|  | @ -67,10 +62,6 @@ class Groups::RunnersController < Groups::ApplicationController | |||
| 
 | ||||
|     render_404 | ||||
|   end | ||||
| 
 | ||||
|   def create_runner_workflow_for_namespace_enabled? | ||||
|     Feature.enabled?(:create_runner_workflow_for_namespace, group) | ||||
|   end | ||||
| end | ||||
| 
 | ||||
| Groups::RunnersController.prepend_mod | ||||
|  |  | |||
|  | @ -24,11 +24,10 @@ class Projects::RunnersController < Projects::ApplicationController | |||
|   end | ||||
| 
 | ||||
|   def new | ||||
|     render_404 unless create_runner_workflow_for_namespace_enabled? | ||||
|   end | ||||
| 
 | ||||
|   def register | ||||
|     render_404 unless create_runner_workflow_for_namespace_enabled? && runner.registration_available? | ||||
|     render_404 unless runner.registration_available? | ||||
|   end | ||||
| 
 | ||||
|   def destroy | ||||
|  | @ -84,8 +83,4 @@ class Projects::RunnersController < Projects::ApplicationController | |||
|   def runner_params | ||||
|     params.require(:runner).permit(Ci::Runner::FORM_EDITABLE) | ||||
|   end | ||||
| 
 | ||||
|   def create_runner_workflow_for_namespace_enabled? | ||||
|     Feature.enabled?(:create_runner_workflow_for_namespace, project.namespace) | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -15,7 +15,6 @@ module Projects | |||
|       before_action do | ||||
|         push_frontend_feature_flag(:ci_variables_pages, current_user) | ||||
|         push_frontend_feature_flag(:ci_limit_environment_scope, @project) | ||||
|         push_frontend_feature_flag(:create_runner_workflow_for_namespace, @project.namespace) | ||||
|         push_frontend_feature_flag(:frozen_outbound_job_token_scopes, @project) | ||||
|         push_frontend_feature_flag(:frozen_outbound_job_token_scopes_override, @project) | ||||
|       end | ||||
|  |  | |||
|  | @ -37,8 +37,6 @@ module Mutations | |||
| 
 | ||||
|           parse_gid(**args) | ||||
| 
 | ||||
|           check_feature_flag(**args) | ||||
| 
 | ||||
|           super | ||||
|         end | ||||
| 
 | ||||
|  | @ -79,28 +77,6 @@ module Mutations | |||
|             GitlabSchema.parse_gid(args[:project_id], expected_type: ::Project) | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         def check_feature_flag(**args) | ||||
|           case args[:runner_type] | ||||
|           when 'instance_type' | ||||
|             if Feature.disabled?(:create_runner_workflow_for_admin, current_user) | ||||
|               raise Gitlab::Graphql::Errors::ResourceNotAvailable, | ||||
|                 '`create_runner_workflow_for_admin` feature flag is disabled.' | ||||
|             end | ||||
|           when 'group_type' | ||||
|             namespace = find_object(**args).sync | ||||
|             if Feature.disabled?(:create_runner_workflow_for_namespace, namespace) | ||||
|               raise Gitlab::Graphql::Errors::ResourceNotAvailable, | ||||
|                 '`create_runner_workflow_for_namespace` feature flag is disabled.' | ||||
|             end | ||||
|           when 'project_type' | ||||
|             project = find_object(**args).sync | ||||
|             if project && Feature.disabled?(:create_runner_workflow_for_namespace, project.namespace) | ||||
|               raise Gitlab::Graphql::Errors::ResourceNotAvailable, | ||||
|                 '`create_runner_workflow_for_namespace` feature flag is disabled.' | ||||
|             end | ||||
|           end | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  |  | |||
|  | @ -22,10 +22,6 @@ class GlobalPolicy < BasePolicy | |||
|   condition(:project_bot, scope: :user) { @user&.project_bot? } | ||||
|   condition(:migration_bot, scope: :user) { @user&.migration_bot? } | ||||
| 
 | ||||
|   condition(:create_runner_workflow_enabled, scope: :user) do | ||||
|     Feature.enabled?(:create_runner_workflow_for_admin, @user) | ||||
|   end | ||||
| 
 | ||||
|   condition(:service_account, scope: :user) { @user&.service_account? } | ||||
| 
 | ||||
|   rule { anonymous }.policy do | ||||
|  | @ -128,10 +124,6 @@ class GlobalPolicy < BasePolicy | |||
|     enable :create_instance_runner | ||||
|   end | ||||
| 
 | ||||
|   rule { ~create_runner_workflow_enabled }.policy do | ||||
|     prevent :create_instance_runner | ||||
|   end | ||||
| 
 | ||||
|   # We can't use `read_statistics` because the user may have different permissions for different projects | ||||
|   rule { admin }.enable :use_project_statistics_filters | ||||
| 
 | ||||
|  |  | |||
|  | @ -97,10 +97,6 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy | |||
|   with_scope :subject | ||||
|   condition(:crm_enabled, score: 0, scope: :subject) { @subject.crm_enabled? } | ||||
| 
 | ||||
|   condition(:create_runner_workflow_enabled) do | ||||
|     Feature.enabled?(:create_runner_workflow_for_namespace, group) | ||||
|   end | ||||
| 
 | ||||
|   condition(:achievements_enabled, scope: :subject) do | ||||
|     Feature.enabled?(:achievements, @subject) | ||||
|   end | ||||
|  | @ -375,10 +371,6 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy | |||
|     enable :admin_observability | ||||
|   end | ||||
| 
 | ||||
|   rule { ~create_runner_workflow_enabled }.policy do | ||||
|     prevent :create_runner | ||||
|   end | ||||
| 
 | ||||
|   # Should be matched with ProjectPolicy#read_internal_note | ||||
|   rule { admin | reporter }.enable :read_internal_note | ||||
| 
 | ||||
|  |  | |||
|  | @ -253,10 +253,6 @@ class ProjectPolicy < BasePolicy | |||
|     !Gitlab.config.terraform_state.enabled | ||||
|   end | ||||
| 
 | ||||
|   condition(:create_runner_workflow_enabled) do | ||||
|     Feature.enabled?(:create_runner_workflow_for_namespace, project.namespace) | ||||
|   end | ||||
| 
 | ||||
|   condition(:namespace_catalog_available) { namespace_catalog_available? } | ||||
| 
 | ||||
|   # `:read_project` may be prevented in EE, but `:read_project_for_iids` should | ||||
|  | @ -886,10 +882,6 @@ class ProjectPolicy < BasePolicy | |||
|     enable :read_code | ||||
|   end | ||||
| 
 | ||||
|   rule { ~create_runner_workflow_enabled }.policy do | ||||
|     prevent :create_runner | ||||
|   end | ||||
| 
 | ||||
|   # Should be matched with GroupPolicy#read_internal_note | ||||
|   rule { admin | can?(:reporter_access) }.enable :read_internal_note | ||||
| 
 | ||||
|  |  | |||
|  | @ -7,12 +7,12 @@ | |||
| - if Feature.enabled?(:edit_user_profile_vue, current_user) | ||||
|   .js-user-profile | ||||
| - else | ||||
|   = gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user js-edit-user gl-mt-3 js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f| | ||||
|     .row.js-search-settings-section | ||||
|       .col-lg-4.profile-settings-sidebar | ||||
|         %h4.gl-mt-0 | ||||
|   = gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user js-edit-user js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f| | ||||
|     .js-search-settings-section.gl-pb-6 | ||||
|       .profile-settings-sidebar | ||||
|         %h4.gl-my-0 | ||||
|           = s_("Profiles|Public avatar") | ||||
|         %p | ||||
|         %p.gl-text-secondary | ||||
|           - if @user.avatar? | ||||
|             - if gravatar_enabled? | ||||
|               = s_("Profiles|You can change your avatar here or remove the current avatar to revert to %{gravatar_link}").html_safe % { gravatar_link: gravatar_link } | ||||
|  | @ -26,31 +26,29 @@ | |||
|           - if current_appearance&.profile_image_guidelines? | ||||
|             .md | ||||
|               = brand_profile_image_guidelines | ||||
|       .col-lg-8 | ||||
|         .avatar-image | ||||
|           = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do | ||||
|             = render Pajamas::AvatarComponent.new(@user, size: 96, alt: "", class: 'gl-float-left gl-mr-5') | ||||
|         %h5.gl-mt-0= s_("Profiles|Upload new avatar") | ||||
|         .gl-display-flex.gl-align-items-center.gl-my-3 | ||||
|           = render Pajamas::ButtonComponent.new(button_options: { class: 'js-choose-user-avatar-button' }) do | ||||
|             = s_("Profiles|Choose file...") | ||||
|           %span.gl-ml-3.js-avatar-filename= s_("Profiles|No file chosen.") | ||||
|           = f.file_field :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*' | ||||
|         .gl-text-gray-500= s_("Profiles|The maximum file size allowed is 200KB.") | ||||
|         - if @user.avatar? | ||||
|           = render Pajamas::ButtonComponent.new(variant: :danger, | ||||
|             category: :secondary, | ||||
|             href: profile_avatar_path, | ||||
|             button_options: { class: 'gl-mt-3', data: { confirm: s_("Profiles|Avatar will be removed. Are you sure?") } }, | ||||
|             method: :delete) do | ||||
|             = s_("Profiles|Remove avatar") | ||||
|       .col-lg-12 | ||||
|         %hr | ||||
|     .row.js-search-settings-section | ||||
|       .col-lg-4.profile-settings-sidebar | ||||
|         %h4.gl-mt-0= s_("Profiles|Current status") | ||||
|         %p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.") | ||||
|       .col-lg-8 | ||||
|       .avatar-image | ||||
|         = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do | ||||
|           = render Pajamas::AvatarComponent.new(@user, size: 96, alt: "", class: 'gl-float-left gl-mr-5') | ||||
|       %h5.gl-mt-0= s_("Profiles|Upload new avatar") | ||||
|       .gl-display-flex.gl-align-items-center.gl-my-3 | ||||
|         = render Pajamas::ButtonComponent.new(button_options: { class: 'js-choose-user-avatar-button' }) do | ||||
|           = s_("Profiles|Choose file...") | ||||
|         %span.gl-ml-3.js-avatar-filename= s_("Profiles|No file chosen.") | ||||
|         = f.file_field :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*' | ||||
|       .gl-text-gray-500= s_("Profiles|The maximum file size allowed is 200KB.") | ||||
|       - if @user.avatar? | ||||
|         = render Pajamas::ButtonComponent.new(variant: :danger, | ||||
|           category: :secondary, | ||||
|           href: profile_avatar_path, | ||||
|           button_options: { class: 'gl-mt-3', data: { confirm: s_("Profiles|Avatar will be removed. Are you sure?") } }, | ||||
|           method: :delete) do | ||||
|           = s_("Profiles|Remove avatar") | ||||
| 
 | ||||
|     .js-search-settings-section.gl-border-t.gl-py-6 | ||||
|       .profile-settings-sidebar | ||||
|         %h4.gl-my-0= s_("Profiles|Current status") | ||||
|         %p.gl-text-secondary= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.") | ||||
|       .gl-max-w-80 | ||||
|         #js-user-profile-set-status-form | ||||
|           = f.fields_for :status, @user.status do |status_form| | ||||
|             = status_form.hidden_field :emoji, data: { js_name: 'emoji' } | ||||
|  | @ -59,121 +57,114 @@ | |||
|             = status_form.hidden_field :clear_status_after, | ||||
|               value: user_clear_status_at(@user), | ||||
|               data: { js_name: 'clearStatusAfter' } | ||||
|       .col-lg-12 | ||||
|         %hr | ||||
|     .row.user-time-preferences.js-search-settings-section | ||||
|       .col-lg-4.profile-settings-sidebar | ||||
|         %h4.gl-mt-0= s_("Profiles|Time settings") | ||||
|         %p= s_("Profiles|Set your local time zone.") | ||||
|       .col-lg-8 | ||||
|         = f.label :user_timezone, _("Time zone") | ||||
|         .js-timezone-dropdown{ data: { timezone_data: timezone_data.to_json, initial_value: @user.timezone, name: 'user[timezone]' } } | ||||
|       .col-lg-12 | ||||
|         %hr | ||||
|     .row.js-search-settings-section | ||||
|       .col-lg-4.profile-settings-sidebar | ||||
|         %h4.gl-mt-0 | ||||
| 
 | ||||
|     .user-time-preferences.js-search-settings-section.gl-border-t.gl-py-6 | ||||
|       .profile-settings-sidebar | ||||
|         %h4.gl-my-0= s_("Profiles|Time settings") | ||||
|         %p.gl-text-secondary= s_("Profiles|Set your local time zone.") | ||||
|       = f.label :user_timezone, _("Time zone") | ||||
|       .js-timezone-dropdown{ data: { timezone_data: timezone_data.to_json, initial_value: @user.timezone, name: 'user[timezone]' } } | ||||
| 
 | ||||
|     .js-search-settings-section.gl-border-t.gl-py-6 | ||||
|       .profile-settings-sidebar | ||||
|         %h4.gl-my-0 | ||||
|           = s_("Profiles|Main settings") | ||||
|         %p | ||||
|         %p.gl-text-secondary | ||||
|           = s_("Profiles|This information will appear on your profile.") | ||||
|           - if current_user.ldap_user? | ||||
|             = s_("Profiles|Some options are unavailable for LDAP accounts") | ||||
|       .col-lg-8 | ||||
|         .row | ||||
|           .form-group.gl-form-group.col-md-9.rspec-full-name | ||||
|             = render 'profiles/name', form: f, user: @user | ||||
|           .form-group.gl-form-group.col-md-3 | ||||
|             = f.label :id, s_('Profiles|User ID') | ||||
|             = f.text_field :id, class: 'gl-form-input form-control', readonly: true | ||||
|         .form-group.gl-form-group | ||||
|           = f.label :pronouns, s_('Profiles|Pronouns') | ||||
|           = f.text_field :pronouns, class: 'gl-form-input form-control gl-md-form-input-lg' | ||||
|           %small.form-text.text-gl-muted | ||||
|             = s_("Profiles|Enter your pronouns to let people know how to refer to you.") | ||||
|         .form-group.gl-form-group | ||||
|           = f.label :pronunciation, s_('Profiles|Pronunciation') | ||||
|           = f.text_field :pronunciation, class: 'gl-form-input form-control gl-md-form-input-lg' | ||||
|           %small.form-text.text-gl-muted | ||||
|             = s_("Profiles|Enter how your name is pronounced to help people address you correctly.") | ||||
|         = render_if_exists 'profiles/extra_settings', form: f | ||||
|         = render_if_exists 'profiles/email_settings', form: f | ||||
|         .form-group.gl-form-group | ||||
|           = f.label :skype | ||||
|           = f.text_field :skype, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|username") | ||||
|         .form-group.gl-form-group | ||||
|           = f.label :linkedin | ||||
|           = f.text_field :linkedin, class: 'gl-form-input form-control gl-md-form-input-lg' | ||||
|           %small.form-text.text-gl-muted | ||||
|             = s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename") | ||||
|         .form-group.gl-form-group | ||||
|           = f.label :twitter | ||||
|           = f.text_field :twitter, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|@username") | ||||
|         .form-group.gl-form-group | ||||
|           - external_accounts_help_url = help_page_path('user/profile/index', anchor: 'add-external-accounts-to-your-user-profile-page') | ||||
|           - external_accounts_link = link_to '', external_accounts_help_url, target: "_blank", rel: "noopener noreferrer" | ||||
|           - external_accounts_docs_link = safe_format(s_('Profiles|Your Discord user ID. %{external_accounts_link_start}Learn more.%{external_accounts_link_end}'), tag_pair(external_accounts_link, :external_accounts_link_start, :external_accounts_link_end)) | ||||
|           - min_discord_length = 17 | ||||
|           - max_discord_length = 20 | ||||
|           = f.label :discord | ||||
|           = f.text_field :discord, | ||||
|             class: 'gl-form-input form-control gl-md-form-input-lg js-validate-length', | ||||
|             placeholder: s_("Profiles|User ID"), | ||||
|             data: { min_length: min_discord_length, | ||||
|             min_length_message: s_('Profiles|Discord ID is too short (minimum is %{min_length} characters).') % { min_length: min_discord_length }, | ||||
|             max_length: max_discord_length, | ||||
|             max_length_message: s_('Profiles|Discord ID is too long (maximum is %{max_length} characters).') % { max_length: max_discord_length }, | ||||
|             allow_empty: true} | ||||
|           %small.form-text.text-gl-muted | ||||
|             = external_accounts_docs_link | ||||
|       .form-group.gl-form-group.rspec-full-name.gl-max-w-80 | ||||
|         = render 'profiles/name', form: f, user: @user | ||||
|       .form-group.gl-form-group.gl-md-form-input-lg | ||||
|         = f.label :id, s_('Profiles|User ID') | ||||
|         = f.text_field :id, class: 'gl-form-input form-control', readonly: true | ||||
|       .form-group.gl-form-group | ||||
|         = f.label :pronouns, s_('Profiles|Pronouns') | ||||
|         = f.text_field :pronouns, class: 'gl-form-input form-control gl-md-form-input-lg' | ||||
|         %small.form-text.text-gl-muted | ||||
|           = s_("Profiles|Enter your pronouns to let people know how to refer to you.") | ||||
|       .form-group.gl-form-group | ||||
|         = f.label :pronunciation, s_('Profiles|Pronunciation') | ||||
|         = f.text_field :pronunciation, class: 'gl-form-input form-control gl-md-form-input-lg' | ||||
|         %small.form-text.text-gl-muted | ||||
|           = s_("Profiles|Enter how your name is pronounced to help people address you correctly.") | ||||
|       = render_if_exists 'profiles/extra_settings', form: f | ||||
|       = render_if_exists 'profiles/email_settings', form: f | ||||
|       .form-group.gl-form-group | ||||
|         = f.label :skype | ||||
|         = f.text_field :skype, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|username") | ||||
|       .form-group.gl-form-group | ||||
|         = f.label :linkedin | ||||
|         = f.text_field :linkedin, class: 'gl-form-input form-control gl-md-form-input-lg' | ||||
|         %small.form-text.text-gl-muted | ||||
|           = s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename") | ||||
|       .form-group.gl-form-group | ||||
|         = f.label :twitter | ||||
|         = f.text_field :twitter, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|@username") | ||||
|       .form-group.gl-form-group | ||||
|         - external_accounts_help_url = help_page_path('user/profile/index', anchor: 'add-external-accounts-to-your-user-profile-page') | ||||
|         - external_accounts_link = link_to '', external_accounts_help_url, target: "_blank", rel: "noopener noreferrer" | ||||
|         - external_accounts_docs_link = safe_format(s_('Profiles|Your Discord user ID. %{external_accounts_link_start}Learn more.%{external_accounts_link_end}'), tag_pair(external_accounts_link, :external_accounts_link_start, :external_accounts_link_end)) | ||||
|         - min_discord_length = 17 | ||||
|         - max_discord_length = 20 | ||||
|         = f.label :discord | ||||
|         = f.text_field :discord, | ||||
|           class: 'gl-form-input form-control gl-md-form-input-lg js-validate-length', | ||||
|           placeholder: s_("Profiles|User ID"), | ||||
|           data: { min_length: min_discord_length, | ||||
|           min_length_message: s_('Profiles|Discord ID is too short (minimum is %{min_length} characters).') % { min_length: min_discord_length }, | ||||
|           max_length: max_discord_length, | ||||
|           max_length_message: s_('Profiles|Discord ID is too long (maximum is %{max_length} characters).') % { max_length: max_discord_length }, | ||||
|           allow_empty: true} | ||||
|         %small.form-text.text-gl-muted | ||||
|           = external_accounts_docs_link | ||||
| 
 | ||||
|         .form-group.gl-form-group | ||||
|           = f.label :website_url, s_('Profiles|Website url') | ||||
|           = f.text_field :website_url, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|https://website.com") | ||||
|         .form-group.gl-form-group | ||||
|           = f.label :location, s_('Profiles|Location') | ||||
|           - if @user.read_only_attribute?(:location) | ||||
|             = f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', readonly: true | ||||
|             %small.form-text.text-gl-muted | ||||
|               = s_("Profiles|Your location was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:location) } | ||||
|           - else | ||||
|             = f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|City, country") | ||||
|         .form-group.gl-form-group | ||||
|           = f.label :job_title, s_('Profiles|Job title') | ||||
|           = f.text_field :job_title, class: 'gl-form-input form-control gl-md-form-input-lg' | ||||
|         .form-group.gl-form-group | ||||
|           = f.label :organization, s_('Profiles|Organization') | ||||
|           = f.text_field :organization, class: 'gl-form-input form-control gl-md-form-input-lg' | ||||
|       .form-group.gl-form-group | ||||
|         = f.label :website_url, s_('Profiles|Website url') | ||||
|         = f.text_field :website_url, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|https://website.com") | ||||
|       .form-group.gl-form-group | ||||
|         = f.label :location, s_('Profiles|Location') | ||||
|         - if @user.read_only_attribute?(:location) | ||||
|           = f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', readonly: true | ||||
|           %small.form-text.text-gl-muted | ||||
|             = s_("Profiles|Who you represent or work for.") | ||||
|         .form-group.gl-form-group | ||||
|           = f.label :bio, s_('Profiles|Bio') | ||||
|           = f.text_area :bio, class: 'gl-form-input gl-form-textarea form-control', rows: 4, maxlength: 250 | ||||
|           %small.form-text.text-gl-muted | ||||
|             = s_("Profiles|Tell us about yourself in fewer than 250 characters.") | ||||
|         %hr | ||||
|             = s_("Profiles|Your location was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:location) } | ||||
|         - else | ||||
|           = f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|City, country") | ||||
|       .form-group.gl-form-group | ||||
|         = f.label :job_title, s_('Profiles|Job title') | ||||
|         = f.text_field :job_title, class: 'gl-form-input form-control gl-md-form-input-lg' | ||||
|       .form-group.gl-form-group | ||||
|         = f.label :organization, s_('Profiles|Organization') | ||||
|         = f.text_field :organization, class: 'gl-form-input form-control gl-md-form-input-lg' | ||||
|         %small.form-text.text-gl-muted | ||||
|           = s_("Profiles|Who you represent or work for.") | ||||
|       .form-group.gl-form-group.gl-mb-6.gl-max-w-80 | ||||
|         = f.label :bio, s_('Profiles|Bio') | ||||
|         = f.text_area :bio, class: 'gl-form-input gl-form-textarea form-control', rows: 4, maxlength: 250 | ||||
|         %small.form-text.text-gl-muted | ||||
|           = s_("Profiles|Tell us about yourself in fewer than 250 characters.") | ||||
|       .gl-border-t.gl-pt-6 | ||||
|         %fieldset.form-group.gl-form-group | ||||
|           %legend.col-form-label.col-form-label | ||||
|           %legend.col-form-label | ||||
|             = _('Private profile') | ||||
|           - private_profile_label = s_("Profiles|Don't display activity-related personal information on your profile.") | ||||
|           - private_profile_help_link = link_to sprite_icon('question-o'), help_page_path('user/profile/index.md', anchor: 'make-your-user-profile-page-private') | ||||
|           = f.gitlab_ui_checkbox_component :private_profile, '%{private_profile_label} %{private_profile_help_link}'.html_safe % { private_profile_label: private_profile_label, private_profile_help_link: private_profile_help_link.html_safe } | ||||
|         %fieldset.form-group.gl-form-group | ||||
|           %legend.col-form-label.col-form-label | ||||
|           %legend.col-form-label | ||||
|             = s_("Profiles|Private contributions") | ||||
|           = f.gitlab_ui_checkbox_component :include_private_contributions, | ||||
|             s_('Profiles|Include private contributions on your profile'), | ||||
|             help_text: s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information.") | ||||
|         %fieldset.form-group.gl-form-group | ||||
|           %legend.col-form-label.col-form-label | ||||
|         %fieldset.form-group.gl-form-group.gl-mb-0 | ||||
|           %legend.col-form-label | ||||
|             = s_("Profiles|Achievements") | ||||
|           = f.gitlab_ui_checkbox_component :achievements_enabled, | ||||
|             s_('Profiles|Display achievements on your profile') | ||||
|     .row.js-hide-when-nothing-matches-search | ||||
|       .col-lg-12 | ||||
|         %hr | ||||
|         = f.submit s_("Profiles|Update profile settings"), class: 'gl-mr-3 js-password-prompt-btn', pajamas_button: true | ||||
|         = render Pajamas::ButtonComponent.new(href: user_path(current_user)) do | ||||
|           = s_('TagsPage|Cancel') | ||||
|     .js-hide-when-nothing-matches-search.gl-border-t.gl-py-6 | ||||
|       = f.submit s_("Profiles|Update profile settings"), class: 'gl-mr-3 js-password-prompt-btn', pajamas_button: true | ||||
|       = render Pajamas::ButtonComponent.new(href: user_path(current_user)) do | ||||
|         = s_('TagsPage|Cancel') | ||||
| 
 | ||||
|   #password-prompt-modal | ||||
| 
 | ||||
|  |  | |||
|  | @ -3,26 +3,14 @@ | |||
| 
 | ||||
| .bs-callout.help-callout | ||||
|   %p= s_('Runners|These runners are assigned to this project.') | ||||
|   - if Feature.enabled?(:create_runner_workflow_for_namespace, @project.namespace) | ||||
|     - if can?(current_user, :create_runner, @project) | ||||
|       = render Pajamas::ButtonComponent.new(href: new_project_runner_path(@project), variant: :confirm) do | ||||
|         = s_('Runners|New project runner') | ||||
|       .gl-display-inline | ||||
|         #js-project-runner-registration-dropdown{ data: { registration_token: @project.runners_token, project_id: @project.id } } | ||||
|     - else | ||||
|       = _('Please contact an admin to create runners.') | ||||
|       = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'restrict-runner-registration-by-all-users-in-an-instance'), target: '_blank', rel: 'noopener noreferrer' | ||||
|   - if can?(current_user, :create_runner, @project) | ||||
|     = render Pajamas::ButtonComponent.new(href: new_project_runner_path(@project), variant: :confirm) do | ||||
|       = s_('Runners|New project runner') | ||||
|     .gl-display-inline | ||||
|       #js-project-runner-registration-dropdown{ data: { registration_token: @project.runners_token, project_id: @project.id } } | ||||
|   - else | ||||
|     - if can?(current_user, :register_project_runners, @project) | ||||
|       = render partial: 'ci/runner/how_to_setup_runner', | ||||
|               locals: { registration_token: @project.runners_token, | ||||
|                         type: _('project'), | ||||
|                         reset_token_url: reset_registration_token_namespace_project_settings_ci_cd_path, | ||||
|                         project_path: @project.path_with_namespace, | ||||
|                         group_path: '' } | ||||
|     - else | ||||
|       = _('Please contact an admin to register runners.') | ||||
|       = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'restrict-runner-registration-by-all-users-in-an-instance'), target: '_blank', rel: 'noopener noreferrer' | ||||
|     = _('Please contact an admin to create runners.') | ||||
|     = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'restrict-runner-registration-by-all-users-in-an-instance'), target: '_blank', rel: 'noopener noreferrer' | ||||
| 
 | ||||
| %hr | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,8 +0,0 @@ | |||
| --- | ||||
| name: create_runner_workflow_for_admin | ||||
| introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/109497 | ||||
| rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/389269 | ||||
| milestone: '15.9' | ||||
| type: development | ||||
| group: group::runner | ||||
| default_enabled: true | ||||
|  | @ -1,8 +0,0 @@ | |||
| --- | ||||
| name: create_runner_workflow_for_namespace | ||||
| introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/113535 | ||||
| rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/393919 | ||||
| milestone: '15.10' | ||||
| type: development | ||||
| group: group::runner | ||||
| default_enabled: true | ||||
|  | @ -1,7 +1,7 @@ | |||
| --- | ||||
| data_category: optional | ||||
| key_path: usage_activity_by_stage_monthly.plan.service_desk_enabled_projects | ||||
| description: Count creator ids from projects with service desk enabled | ||||
| description: Count unique users who created projects (creator_id) with service desk enabled | ||||
| product_section: ops | ||||
| product_stage: monitor | ||||
| product_group: respond | ||||
|  |  | |||
|  | @ -1,7 +1,7 @@ | |||
| --- | ||||
| data_category: optional | ||||
| key_path: usage_activity_by_stage.plan.service_desk_enabled_projects | ||||
| description: Count creator ids from projects with service desk enabled | ||||
| description: Count unique users who created projects (creator_id) with service desk enabled | ||||
| product_section: ops | ||||
| product_stage: monitor | ||||
| product_group: respond | ||||
|  |  | |||
|  | @ -6,6 +6,9 @@ development: | |||
|   cluster_cache: | ||||
|     cluster: | ||||
|       - redis://localhost:7001 | ||||
|   feature_flag: | ||||
|     cluster: | ||||
|       - redis://localhost:7001 | ||||
|   rate_limiting: | ||||
|     cluster: | ||||
|       - redis://localhost:7001 | ||||
|  | @ -17,6 +20,9 @@ test: | |||
|   cluster_cache: | ||||
|     cluster: | ||||
|       - redis://localhost:7001 | ||||
|   feature_flag: | ||||
|     cluster: | ||||
|       - redis://localhost:7001 | ||||
|   rate_limiting: | ||||
|     cluster: | ||||
|       - redis://localhost:7001 | ||||
|  |  | |||
|  | @ -105,8 +105,6 @@ concurrency is set to: | |||
| When `min_concurrency` is greater than `max_concurrency`, it is treated as | ||||
| being equal to `max_concurrency`. | ||||
| 
 | ||||
| You can find example values used by GitLab.com by searching for `concurrency:` | ||||
| in [the Helm charts](https://gitlab.com/gitlab-com/gl-infra/k8s-workloads/gitlab-com/-/blob/master/releases/gitlab/values/gprd.yaml.gotmpl). | ||||
| The values vary according to the work each specific deployment of Sidekiq does. | ||||
| Any other specialized deployments with processes dedicated to specific queues | ||||
| should have the concurrency tuned according to: | ||||
|  |  | |||
|  | @ -38,10 +38,7 @@ If you are using GitLab.com: | |||
| 
 | ||||
| > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/383139) in GitLab 15.10. Deployed behind the `create_runner_workflow_for_admin` [flag](../../administration/feature_flags.md) | ||||
| > - [Enabled by default](https://gitlab.com/gitlab-org/gitlab/-/issues/389269) in GitLab 16.0. | ||||
| 
 | ||||
| FLAG: | ||||
| On self-managed GitLab, by default this feature is available. To hide the feature, | ||||
| ask an administrator to [disable the feature flag](../../administration/feature_flags.md) named `create_runner_workflow_for_admin`. | ||||
| > - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/415447) in GitLab 16.2. Feature flag `create_runner_workflow_for_admin` removed. | ||||
| 
 | ||||
| Prerequisites: | ||||
| 
 | ||||
|  | @ -195,10 +192,7 @@ Group runners process jobs by using a first in, first out ([FIFO](https://en.wik | |||
| 
 | ||||
| > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/383143) in GitLab 15.10. Deployed behind the `create_runner_workflow_for_namespace` [flag](../../administration/feature_flags.md). Disabled by default. | ||||
| > - [Enabled by default](https://gitlab.com/gitlab-org/gitlab/-/issues/393919) in GitLab 16.0. | ||||
| 
 | ||||
| FLAG: | ||||
| On self-managed GitLab, by default this feature is available. To hide the feature, | ||||
| ask an administrator to [disable the feature flag](../../administration/feature_flags.md) named `create_runner_workflow_for_namespace`. | ||||
| > - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/415447) in GitLab 16.2. Feature flag `create_runner_workflow_for_admin` removed. | ||||
| 
 | ||||
| Prerequisites: | ||||
| 
 | ||||
|  | @ -328,10 +322,7 @@ A fork *does* copy the CI/CD settings of the cloned repository. | |||
| 
 | ||||
| > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/383143) in GitLab 15.10. Deployed behind the `create_runner_workflow_for_namespace` [flag](../../administration/feature_flags.md). Disabled by default. | ||||
| > - [Enabled by default](https://gitlab.com/gitlab-org/gitlab/-/issues/393919) in GitLab 16.0. | ||||
| 
 | ||||
| FLAG: | ||||
| On self-managed GitLab, by default this feature is available. To hide the feature, | ||||
| ask an administrator to [disable the feature flag](../../administration/feature_flags.md) named `create_runner_workflow_for_namespace`. | ||||
| > - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/415447) in GitLab 16.2. Feature flag `create_runner_workflow_for_admin` removed. | ||||
| 
 | ||||
| Prerequisites: | ||||
| 
 | ||||
|  |  | |||
|  | @ -863,9 +863,9 @@ PUT /namespaces/:id/subscription_add_on_purchase/:add_on_name | |||
| 
 | ||||
| | Attribute   | Type    | Required | Description | | ||||
| |:------------|:--------|:---------|:------------| | ||||
| | `quantity` | integer | yes | Amount of units in the subscription add-on purchase (Example: Number of seats for a code suggestions add-on) | | ||||
| | `quantity` | integer | no | Amount of units in the subscription add-on purchase (Example: Number of seats for a code suggestions add-on) | | ||||
| | `expires_on` | date | yes | Expiration date of the subscription add-on purchase | | ||||
| | `purchase_xid` | string | yes | Identifier for the subscription add-on purchase (Example: Subscription name for a code suggestions add-on) | | ||||
| | `purchase_xid` | string | no | Identifier for the subscription add-on purchase (Example: Subscription name for a code suggestions add-on) | | ||||
| 
 | ||||
| Example request: | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| --- | ||||
| stage: Create | ||||
| group: Ecosystem | ||||
| stage: Manage | ||||
| group: Authentication and Authorization | ||||
| 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/#assignments | ||||
| --- | ||||
| 
 | ||||
|  |  | |||
|  | @ -347,12 +347,13 @@ If you have difficulty during the renewal process, contact the | |||
| 
 | ||||
| Contacts can renew a subscription, cancel a subscription, or transfer the subscription to a different namespace. | ||||
| 
 | ||||
| To change the contacts: | ||||
| For information about how to transfer ownership of the Customers Portal account to another person, see | ||||
| [Change account owner information](../customers_portal.md#change-account-owner-information). | ||||
| 
 | ||||
| To add a secondary contact for your subscription: | ||||
| 
 | ||||
| 1. Ensure an account exists in the | ||||
|    [Customers Portal](https://customers.gitlab.com/customers/sign_in) for the user you want to add. | ||||
| 1. Verify you have access to at least one of | ||||
|    [these requirements](https://about.gitlab.com/handbook/support/license-and-renewals/workflows/customersdot/associating_purchases.html). | ||||
| 1. [Create a ticket with the Support team](https://support.gitlab.com/hc/en-us/requests/new?ticket_form_id=360000071293). Include any relevant material in your request. | ||||
| 
 | ||||
| ## Compute | ||||
|  |  | |||
|  | @ -428,12 +428,13 @@ The new tier takes effect when the new license is activated. | |||
| 
 | ||||
| Contacts can renew a subscription, cancel a subscription, or transfer the subscription to a different namespace. | ||||
| 
 | ||||
| To change the contacts: | ||||
| For information about how to transfer ownership of the Customers Portal account to another person, see | ||||
| [Change account owner information](../customers_portal.md#change-account-owner-information). | ||||
| 
 | ||||
| To add a secondary contact for your subscription: | ||||
| 
 | ||||
| 1. Ensure an account exists in the | ||||
|    [Customers Portal](https://customers.gitlab.com/customers/sign_in) for the user you want to add. | ||||
| 1. Verify you have access to at least one of | ||||
|    [these requirements](https://about.gitlab.com/handbook/support/license-and-renewals/workflows/customersdot/associating_purchases.html). | ||||
| 1. [Create a ticket with the Support team](https://support.gitlab.com/hc/en-us/requests/new?ticket_form_id=360000071293). Include any relevant material in your request. | ||||
| 
 | ||||
| ## Subscription expiry | ||||
|  |  | |||
|  | @ -37,6 +37,7 @@ You can use push options to skip a CI/CD pipeline, or pass CI/CD variables. | |||
| | ------------------------------ | ------------------------------------------------------------------------------------------- |---------------------- | | ||||
| | `ci.skip`                      | Do not create a CI pipeline for the latest push. Only skips branch pipelines and not [merge request pipelines](../../ci/pipelines/merge_request_pipelines.md). This does not skip pipelines for CI integrations, such as Jenkins. | [11.7](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/15643) | | ||||
| | `ci.variable="<name>=<value>"` | Provide [CI/CD variables](../../ci/variables/index.md) to be used in a CI pipeline, if one is created due to the push. Only passes variables to branch pipelines and not [merge request pipelines](../../ci/pipelines/merge_request_pipelines.md). | [12.6](https://gitlab.com/gitlab-org/gitlab/-/issues/27983) | | ||||
| | `integrations.skip_ci`         | Skip push events for CI integrations, such as Atlassian Bamboo, Buildkite, Drone, Jenkins, and JetBrains TeamCity. | [16.2](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/123837) | | ||||
| 
 | ||||
| An example of using `ci.skip`: | ||||
| 
 | ||||
|  | @ -50,6 +51,12 @@ An example of passing some CI/CD variables for a pipeline: | |||
| git push -o ci.variable="MAX_RETRIES=10" -o ci.variable="MAX_TIME=600" | ||||
| ``` | ||||
| 
 | ||||
| An example of using `integrations.skip_ci`: | ||||
| 
 | ||||
| ```shell | ||||
| git push -o integrations.skip_ci | ||||
| ``` | ||||
| 
 | ||||
| ## Push options for merge requests | ||||
| 
 | ||||
| You can use Git push options to perform certain actions for merge requests at the same | ||||
|  |  | |||
|  | @ -42,8 +42,10 @@ module Gitlab | |||
|             with_redis do |redis| | ||||
|               Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do | ||||
|                 if Gitlab::Redis::ClusterUtil.cluster?(redis) | ||||
|                   Gitlab::Redis::CrossSlot::Pipeline.new(redis).pipelined do |pipeline| | ||||
|                     keys.each { |key| pipeline.get(key) } | ||||
|                   redis.with_readonly_pipeline do | ||||
|                     Gitlab::Redis::CrossSlot::Pipeline.new(redis).pipelined do |pipeline| | ||||
|                       keys.each { |key| pipeline.get(key) } | ||||
|                     end | ||||
|                   end | ||||
|                 else | ||||
|                   redis.mget(keys) | ||||
|  |  | |||
|  | @ -15,12 +15,7 @@ module Gitlab | |||
|       # | ||||
|       # @return [self] | ||||
|       def self.build(project) | ||||
|         strategy = | ||||
|           if Feature.enabled?(:gitlab_error_tracking, project) | ||||
|             OpenApiStrategy.new(project) | ||||
|           else | ||||
|             ActiveRecordStrategy.new(project) | ||||
|           end | ||||
|         strategy = OpenApiStrategy.new(project) | ||||
| 
 | ||||
|         new(strategy) | ||||
|       end | ||||
|  |  | |||
|  | @ -1,117 +0,0 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Gitlab | ||||
|   module ErrorTracking | ||||
|     class ErrorRepository | ||||
|       class ActiveRecordStrategy | ||||
|         def initialize(project) | ||||
|           @project = project | ||||
|         end | ||||
| 
 | ||||
|         def report_error( | ||||
|           name:, description:, actor:, platform:, | ||||
|           environment:, level:, occurred_at:, payload: | ||||
|         ) | ||||
|           error = project_errors.report_error( | ||||
|             name: name,                # Example: ActionView::MissingTemplate | ||||
|             description: description,  # Example: Missing template posts/show in... | ||||
|             actor: actor,              # Example: PostsController#show | ||||
|             platform: platform,        # Example: ruby | ||||
|             timestamp: occurred_at | ||||
|           ) | ||||
| 
 | ||||
|           # The payload field contains all the data on error including stacktrace in jsonb. | ||||
|           # Together with occurred_at these are 2 main attributes that we need to save here. | ||||
|           error.events.create!( | ||||
|             environment: environment, | ||||
|             description: description, | ||||
|             level: level, | ||||
|             occurred_at: occurred_at, | ||||
|             payload: payload | ||||
|           ) | ||||
|         rescue ActiveRecord::ActiveRecordError => e | ||||
|           handle_exceptions(e) | ||||
|         end | ||||
| 
 | ||||
|         def find_error(id) | ||||
|           project_error(id).to_sentry_detailed_error | ||||
|         rescue ActiveRecord::ActiveRecordError => e | ||||
|           handle_exceptions(e) | ||||
|         end | ||||
| 
 | ||||
|         def list_errors(filters:, query:, sort:, limit:, cursor:) | ||||
|           errors = project_errors | ||||
|           errors = filter_by_status(errors, filters[:status]) | ||||
|           errors = sort(errors, sort) | ||||
|           errors = errors.keyset_paginate(cursor: cursor, per_page: limit) | ||||
|           # query is not supported | ||||
| 
 | ||||
|           pagination = ErrorRepository::Pagination.new(errors.cursor_for_next_page, errors.cursor_for_previous_page) | ||||
| 
 | ||||
|           [errors.map(&:to_sentry_error), pagination] | ||||
|         end | ||||
| 
 | ||||
|         def last_event_for(id) | ||||
|           project_error(id).last_event&.to_sentry_error_event | ||||
|         rescue ActiveRecord::ActiveRecordError => e | ||||
|           handle_exceptions(e) | ||||
|         end | ||||
| 
 | ||||
|         def update_error(id, **attributes) | ||||
|           project_error(id).update(attributes) | ||||
|         end | ||||
| 
 | ||||
|         def dsn_url(public_key) | ||||
|           gitlab = Settings.gitlab | ||||
| 
 | ||||
|           custom_port = Settings.gitlab_on_standard_port? ? nil : ":#{gitlab.port}" | ||||
| 
 | ||||
|           base_url = [ | ||||
|             gitlab.protocol, | ||||
|             "://", | ||||
|             public_key, | ||||
|             '@', | ||||
|             gitlab.host, | ||||
|             custom_port, | ||||
|             gitlab.relative_url_root | ||||
|           ].join('') | ||||
| 
 | ||||
|           "#{base_url}/api/v4/error_tracking/collector/#{project.id}" | ||||
|         end | ||||
| 
 | ||||
|         private | ||||
| 
 | ||||
|         attr_reader :project | ||||
| 
 | ||||
|         def project_errors | ||||
|           ::ErrorTracking::Error.where(project: project) # rubocop:disable CodeReuse/ActiveRecord | ||||
|         end | ||||
| 
 | ||||
|         def project_error(id) | ||||
|           project_errors.find(id) | ||||
|         end | ||||
| 
 | ||||
|         def filter_by_status(errors, status) | ||||
|           return errors unless ::ErrorTracking::Error.statuses.key?(status) | ||||
| 
 | ||||
|           errors.for_status(status) | ||||
|         end | ||||
| 
 | ||||
|         def sort(errors, sort) | ||||
|           return errors.order_id_desc unless sort | ||||
| 
 | ||||
|           errors.sort_by_attribute(sort) | ||||
|         end | ||||
| 
 | ||||
|         def handle_exceptions(exception) | ||||
|           case exception | ||||
|           when ActiveRecord::RecordInvalid | ||||
|             raise RecordInvalidError, exception.message | ||||
|           else | ||||
|             raise DatabaseError, exception.message | ||||
|           end | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -11,9 +11,11 @@ module Gitlab | |||
| 
 | ||||
|           Gitlab::Redis::Cache.with do |r| | ||||
|             Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do | ||||
|               Gitlab::Redis::CrossSlot::Pipeline.new(r).pipelined do |pipeline| | ||||
|                 subjects.each do |subject| | ||||
|                   results[subject.cache_key] = new(subject).read(pipeline) | ||||
|               r.with_readonly_pipeline do | ||||
|                 Gitlab::Redis::CrossSlot::Pipeline.new(r).pipelined do |pipeline| | ||||
|                   subjects.each do |subject| | ||||
|                     results[subject.cache_key] = new(subject).read(pipeline) | ||||
|                   end | ||||
|                 end | ||||
|               end | ||||
|             end | ||||
|  |  | |||
|  | @ -43,7 +43,13 @@ module Gitlab | |||
|         keys = names.map { |name| normalize_key(name, options) } | ||||
| 
 | ||||
|         values = failsafe(:patched_read_multi_mget, returning: {}) do | ||||
|           redis.with { |c| pipeline_mget(c, keys) } | ||||
|           redis.with do |c| | ||||
|             if c.is_a?(Gitlab::Redis::MultiStore) | ||||
|               c.with_readonly_pipeline { pipeline_mget(c, keys) } | ||||
|             else | ||||
|               pipeline_mget(c, keys) | ||||
|             end | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         names.zip(values).each_with_object({}) do |(name, value), results| | ||||
|  |  | |||
|  | @ -24,6 +24,12 @@ module Gitlab | |||
|         end | ||||
|       end | ||||
| 
 | ||||
|       class NestedReadonlyPipelineError < StandardError | ||||
|         def message | ||||
|           'Nested use of with_readonly_pipeline is detected.' | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       attr_reader :primary_store, :secondary_store, :instance_name | ||||
| 
 | ||||
|       FAILED_TO_READ_ERROR_MESSAGE = 'Failed to read from the redis default_store.' | ||||
|  | @ -100,6 +106,25 @@ module Gitlab | |||
|         validate_stores! | ||||
|       end | ||||
| 
 | ||||
|       # Pipelines are sent to both instances by default since | ||||
|       # they could execute both read and write commands. | ||||
|       # | ||||
|       # But for pipelines that only consists of read commands, this method | ||||
|       # can be used to scope the pipeline and send it only to the default store. | ||||
|       def with_readonly_pipeline | ||||
|         raise NestedReadonlyPipelineError if readonly_pipeline? | ||||
| 
 | ||||
|         Thread.current[:readonly_pipeline] = true | ||||
| 
 | ||||
|         yield | ||||
|       ensure | ||||
|         Thread.current[:readonly_pipeline] = false | ||||
|       end | ||||
| 
 | ||||
|       def readonly_pipeline? | ||||
|         Thread.current[:readonly_pipeline].present? | ||||
|       end | ||||
| 
 | ||||
|       # rubocop:disable GitlabSecurity/PublicSend | ||||
|       READ_COMMANDS.each do |name| | ||||
|         define_method(name) do |*args, **kwargs, &block| | ||||
|  | @ -123,7 +148,7 @@ module Gitlab | |||
| 
 | ||||
|       PIPELINED_COMMANDS.each do |name| | ||||
|         define_method(name) do |*args, **kwargs, &block| | ||||
|           if use_primary_and_secondary_stores? | ||||
|           if use_primary_and_secondary_stores? && !readonly_pipeline? | ||||
|             pipelined_both(name, *args, **kwargs, &block) | ||||
|           else | ||||
|             send_command(default_store, name, *args, **kwargs, &block) | ||||
|  |  | |||
|  | @ -34249,9 +34249,6 @@ msgstr "" | |||
| msgid "Please contact an admin to create runners." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Please contact an admin to register runners." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Please contact your GitLab administrator if you think this is an error." | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -39678,9 +39675,6 @@ msgstr "" | |||
| msgid "Runners|Filter projects" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Runners|Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Runners|Get started with runners" | ||||
| msgstr "" | ||||
| 
 | ||||
|  |  | |||
|  | @ -41,18 +41,6 @@ RSpec.describe Admin::RunnersController, feature_category: :runner_fleet do | |||
|       expect(response).to have_gitlab_http_status(:ok) | ||||
|       expect(response).to render_template(:new) | ||||
|     end | ||||
| 
 | ||||
|     context 'when create_runner_workflow_for_admin is disabled' do | ||||
|       before do | ||||
|         stub_feature_flags(create_runner_workflow_for_admin: false) | ||||
|       end | ||||
| 
 | ||||
|       it 'returns :not_found' do | ||||
|         get :new | ||||
| 
 | ||||
|         expect(response).to have_gitlab_http_status(:not_found) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#register' do | ||||
|  | @ -78,20 +66,6 @@ RSpec.describe Admin::RunnersController, feature_category: :runner_fleet do | |||
|         expect(response).to have_gitlab_http_status(:not_found) | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'when create_runner_workflow_for_admin is disabled' do | ||||
|       let_it_be(:new_runner) { create(:ci_runner, registration_type: :authenticated_user) } | ||||
| 
 | ||||
|       before do | ||||
|         stub_feature_flags(create_runner_workflow_for_admin: false) | ||||
|       end | ||||
| 
 | ||||
|       it 'returns :not_found' do | ||||
|         register | ||||
| 
 | ||||
|         expect(response).to have_gitlab_http_status(:not_found) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#edit' do | ||||
|  |  | |||
|  | @ -65,52 +65,28 @@ RSpec.describe Groups::RunnersController, feature_category: :runner_fleet do | |||
|   end | ||||
| 
 | ||||
|   describe '#new' do | ||||
|     context 'when create_runner_workflow_for_namespace is enabled' do | ||||
|     context 'when user is owner' do | ||||
|       before do | ||||
|         stub_feature_flags(create_runner_workflow_for_namespace: [group]) | ||||
|         group.add_owner(user) | ||||
|       end | ||||
| 
 | ||||
|       context 'when user is owner' do | ||||
|         before do | ||||
|           group.add_owner(user) | ||||
|         end | ||||
|       it 'renders new with 200 status code' do | ||||
|         get :new, params: { group_id: group } | ||||
| 
 | ||||
|         it 'renders new with 200 status code' do | ||||
|           get :new, params: { group_id: group } | ||||
| 
 | ||||
|           expect(response).to have_gitlab_http_status(:ok) | ||||
|           expect(response).to render_template(:new) | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'when user is not owner' do | ||||
|         before do | ||||
|           group.add_maintainer(user) | ||||
|         end | ||||
| 
 | ||||
|         it 'renders a 404' do | ||||
|           get :new, params: { group_id: group } | ||||
| 
 | ||||
|           expect(response).to have_gitlab_http_status(:not_found) | ||||
|         end | ||||
|         expect(response).to have_gitlab_http_status(:ok) | ||||
|         expect(response).to render_template(:new) | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'when create_runner_workflow_for_namespace is disabled' do | ||||
|     context 'when user is not owner' do | ||||
|       before do | ||||
|         stub_feature_flags(create_runner_workflow_for_namespace: false) | ||||
|         group.add_maintainer(user) | ||||
|       end | ||||
| 
 | ||||
|       context 'when user is owner' do | ||||
|         before do | ||||
|           group.add_owner(user) | ||||
|         end | ||||
|       it 'renders a 404' do | ||||
|         get :new, params: { group_id: group } | ||||
| 
 | ||||
|         it 'renders a 404' do | ||||
|           get :new, params: { group_id: group } | ||||
| 
 | ||||
|           expect(response).to have_gitlab_http_status(:not_found) | ||||
|         end | ||||
|         expect(response).to have_gitlab_http_status(:not_found) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | @ -118,66 +94,40 @@ RSpec.describe Groups::RunnersController, feature_category: :runner_fleet do | |||
|   describe '#register' do | ||||
|     subject(:register) { get :register, params: { group_id: group, id: new_runner } } | ||||
| 
 | ||||
|     context 'when create_runner_workflow_for_namespace is enabled' do | ||||
|     context 'when user is owner' do | ||||
|       before do | ||||
|         stub_feature_flags(create_runner_workflow_for_namespace: [group]) | ||||
|         group.add_owner(user) | ||||
|       end | ||||
| 
 | ||||
|       context 'when user is owner' do | ||||
|         before do | ||||
|           group.add_owner(user) | ||||
|         end | ||||
|       context 'when runner can be registered after creation' do | ||||
|         let_it_be(:new_runner) { create(:ci_runner, :group, groups: [group], registration_type: :authenticated_user) } | ||||
| 
 | ||||
|         context 'when runner can be registered after creation' do | ||||
|           let_it_be(:new_runner) { create(:ci_runner, :group, groups: [group], registration_type: :authenticated_user) } | ||||
|         it 'renders a :register template' do | ||||
|           register | ||||
| 
 | ||||
|           it 'renders a :register template' do | ||||
|             register | ||||
| 
 | ||||
|             expect(response).to have_gitlab_http_status(:ok) | ||||
|             expect(response).to render_template(:register) | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         context 'when runner cannot be registered after creation' do | ||||
|           let_it_be(:new_runner) { runner } | ||||
| 
 | ||||
|           it 'returns :not_found' do | ||||
|             register | ||||
| 
 | ||||
|             expect(response).to have_gitlab_http_status(:not_found) | ||||
|           end | ||||
|           expect(response).to have_gitlab_http_status(:ok) | ||||
|           expect(response).to render_template(:register) | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'when user is not owner' do | ||||
|         before do | ||||
|           group.add_maintainer(user) | ||||
|         end | ||||
|       context 'when runner cannot be registered after creation' do | ||||
|         let_it_be(:new_runner) { runner } | ||||
| 
 | ||||
|         context 'when runner can be registered after creation' do | ||||
|           let_it_be(:new_runner) { create(:ci_runner, :group, groups: [group], registration_type: :authenticated_user) } | ||||
|         it 'returns :not_found' do | ||||
|           register | ||||
| 
 | ||||
|           it 'returns :not_found' do | ||||
|             register | ||||
| 
 | ||||
|             expect(response).to have_gitlab_http_status(:not_found) | ||||
|           end | ||||
|           expect(response).to have_gitlab_http_status(:not_found) | ||||
|         end | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'when create_runner_workflow_for_namespace is disabled' do | ||||
|       let_it_be(:new_runner) { create(:ci_runner, :group, groups: [group], registration_type: :authenticated_user) } | ||||
| 
 | ||||
|     context 'when user is not owner' do | ||||
|       before do | ||||
|         stub_feature_flags(create_runner_workflow_for_namespace: false) | ||||
|         group.add_maintainer(user) | ||||
|       end | ||||
| 
 | ||||
|       context 'when user is owner' do | ||||
|         before do | ||||
|           group.add_owner(user) | ||||
|         end | ||||
|       context 'when runner can be registered after creation' do | ||||
|         let_it_be(:new_runner) { create(:ci_runner, :group, groups: [group], registration_type: :authenticated_user) } | ||||
| 
 | ||||
|         it 'returns :not_found' do | ||||
|           register | ||||
|  |  | |||
|  | @ -28,52 +28,28 @@ RSpec.describe Projects::RunnersController, feature_category: :runner_fleet do | |||
|       } | ||||
|     end | ||||
| 
 | ||||
|     context 'when create_runner_workflow_for_namespace is enabled' do | ||||
|     context 'when user is maintainer' do | ||||
|       before do | ||||
|         stub_feature_flags(create_runner_workflow_for_namespace: [project.namespace]) | ||||
|         project.add_maintainer(user) | ||||
|       end | ||||
| 
 | ||||
|       context 'when user is maintainer' do | ||||
|         before do | ||||
|           project.add_maintainer(user) | ||||
|         end | ||||
|       it 'renders new with 200 status code' do | ||||
|         get :new, params: params | ||||
| 
 | ||||
|         it 'renders new with 200 status code' do | ||||
|           get :new, params: params | ||||
| 
 | ||||
|           expect(response).to have_gitlab_http_status(:ok) | ||||
|           expect(response).to render_template(:new) | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'when user is not maintainer' do | ||||
|         before do | ||||
|           project.add_developer(user) | ||||
|         end | ||||
| 
 | ||||
|         it 'renders a 404' do | ||||
|           get :new, params: params | ||||
| 
 | ||||
|           expect(response).to have_gitlab_http_status(:not_found) | ||||
|         end | ||||
|         expect(response).to have_gitlab_http_status(:ok) | ||||
|         expect(response).to render_template(:new) | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'when create_runner_workflow_for_namespace is disabled' do | ||||
|     context 'when user is not maintainer' do | ||||
|       before do | ||||
|         stub_feature_flags(create_runner_workflow_for_namespace: false) | ||||
|         project.add_developer(user) | ||||
|       end | ||||
| 
 | ||||
|       context 'when user is maintainer' do | ||||
|         before do | ||||
|           project.add_maintainer(user) | ||||
|         end | ||||
|       it 'renders a 404' do | ||||
|         get :new, params: params | ||||
| 
 | ||||
|         it 'renders a 404' do | ||||
|           get :new, params: params | ||||
| 
 | ||||
|           expect(response).to have_gitlab_http_status(:not_found) | ||||
|         end | ||||
|         expect(response).to have_gitlab_http_status(:not_found) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | @ -81,66 +57,40 @@ RSpec.describe Projects::RunnersController, feature_category: :runner_fleet do | |||
|   describe '#register' do | ||||
|     subject(:register) { get :register, params: { namespace_id: project.namespace, project_id: project, id: new_runner } } | ||||
| 
 | ||||
|     context 'when create_runner_workflow_for_namespace is enabled' do | ||||
|     context 'when user is maintainer' do | ||||
|       before do | ||||
|         stub_feature_flags(create_runner_workflow_for_namespace: [project.namespace]) | ||||
|         project.add_maintainer(user) | ||||
|       end | ||||
| 
 | ||||
|       context 'when user is maintainer' do | ||||
|         before do | ||||
|           project.add_maintainer(user) | ||||
|         end | ||||
|       context 'when runner can be registered after creation' do | ||||
|         let_it_be(:new_runner) { create(:ci_runner, :project, projects: [project], registration_type: :authenticated_user) } | ||||
| 
 | ||||
|         context 'when runner can be registered after creation' do | ||||
|           let_it_be(:new_runner) { create(:ci_runner, :project, projects: [project], registration_type: :authenticated_user) } | ||||
|         it 'renders a :register template' do | ||||
|           register | ||||
| 
 | ||||
|           it 'renders a :register template' do | ||||
|             register | ||||
| 
 | ||||
|             expect(response).to have_gitlab_http_status(:ok) | ||||
|             expect(response).to render_template(:register) | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         context 'when runner cannot be registered after creation' do | ||||
|           let_it_be(:new_runner) { runner } | ||||
| 
 | ||||
|           it 'returns :not_found' do | ||||
|             register | ||||
| 
 | ||||
|             expect(response).to have_gitlab_http_status(:not_found) | ||||
|           end | ||||
|           expect(response).to have_gitlab_http_status(:ok) | ||||
|           expect(response).to render_template(:register) | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'when user is not maintainer' do | ||||
|         before do | ||||
|           project.add_developer(user) | ||||
|         end | ||||
|       context 'when runner cannot be registered after creation' do | ||||
|         let_it_be(:new_runner) { runner } | ||||
| 
 | ||||
|         context 'when runner can be registered after creation' do | ||||
|           let_it_be(:new_runner) { create(:ci_runner, :project, projects: [project], registration_type: :authenticated_user) } | ||||
|         it 'returns :not_found' do | ||||
|           register | ||||
| 
 | ||||
|           it 'returns :not_found' do | ||||
|             register | ||||
| 
 | ||||
|             expect(response).to have_gitlab_http_status(:not_found) | ||||
|           end | ||||
|           expect(response).to have_gitlab_http_status(:not_found) | ||||
|         end | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'when create_runner_workflow_for_namespace is disabled' do | ||||
|       let_it_be(:new_runner) { create(:ci_runner, :project, projects: [project], registration_type: :authenticated_user) } | ||||
| 
 | ||||
|     context 'when user is not maintainer' do | ||||
|       before do | ||||
|         stub_feature_flags(create_runner_workflow_for_namespace: false) | ||||
|         project.add_developer(user) | ||||
|       end | ||||
| 
 | ||||
|       context 'when user is maintainer' do | ||||
|         before do | ||||
|           project.add_maintainer(user) | ||||
|         end | ||||
|       context 'when runner can be registered after creation' do | ||||
|         let_it_be(:new_runner) { create(:ci_runner, :project, projects: [project], registration_type: :authenticated_user) } | ||||
| 
 | ||||
|         it 'returns :not_found' do | ||||
|           register | ||||
|  |  | |||
|  | @ -32,30 +32,13 @@ RSpec.describe "Admin Runners", feature_category: :runner_fleet do | |||
|     end | ||||
| 
 | ||||
|     describe "runners registration" do | ||||
|       context 'when create_runner_workflow_for_namespace is enabled' do | ||||
|         before do | ||||
|           stub_feature_flags(create_runner_workflow_for_admin: true) | ||||
| 
 | ||||
|           visit admin_runners_path | ||||
|         end | ||||
| 
 | ||||
|         it_behaves_like "shows and resets runner registration token" do | ||||
|           let(:dropdown_text) { s_('Runners|Register an instance runner') } | ||||
|           let(:registration_token) { Gitlab::CurrentSettings.runners_registration_token } | ||||
|         end | ||||
|       before do | ||||
|         visit admin_runners_path | ||||
|       end | ||||
| 
 | ||||
|       context 'when create_runner_workflow_for_namespace is disabled' do | ||||
|         before do | ||||
|           stub_feature_flags(create_runner_workflow_for_admin: false) | ||||
| 
 | ||||
|           visit admin_runners_path | ||||
|         end | ||||
| 
 | ||||
|         it_behaves_like "shows and resets runner registration token" do | ||||
|           let(:dropdown_text) { s_('Runners|Register an instance runner') } | ||||
|           let(:registration_token) { Gitlab::CurrentSettings.runners_registration_token } | ||||
|         end | ||||
|       it_behaves_like "shows and resets runner registration token" do | ||||
|         let(:dropdown_text) { s_('Runners|Register an instance runner') } | ||||
|         let(:registration_token) { Gitlab::CurrentSettings.runners_registration_token } | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|  |  | |||
|  | @ -16,21 +16,6 @@ RSpec.describe "Group Runners", feature_category: :runner_fleet do | |||
|   end | ||||
| 
 | ||||
|   describe "Group runners page", :js do | ||||
|     describe "legacy runners registration" do | ||||
|       let_it_be(:group_registration_token) { group.runners_token } | ||||
| 
 | ||||
|       before do | ||||
|         stub_feature_flags(create_runner_workflow_for_namespace: false) | ||||
| 
 | ||||
|         visit group_runners_path(group) | ||||
|       end | ||||
| 
 | ||||
|       it_behaves_like "shows and resets runner registration token" do | ||||
|         let(:dropdown_text) { 'Register a group runner' } | ||||
|         let(:registration_token) { group_registration_token } | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context "with no runners" do | ||||
|       before do | ||||
|         visit group_runners_path(group) | ||||
|  |  | |||
|  | @ -14,17 +14,11 @@ RSpec.describe 'Runners', feature_category: :runner_fleet do | |||
|       stub_feature_flags(project_runners_vue_ui: false) | ||||
|     end | ||||
| 
 | ||||
|     context 'when user views runners page' do | ||||
|       let_it_be(:project) { create(:project) } | ||||
|     context 'with user as project maintainer' do | ||||
|       let_it_be(:project) { create(:project).tap { |project| project.add_maintainer(user) } } | ||||
| 
 | ||||
|       before do | ||||
|         project.add_maintainer(user) | ||||
|       end | ||||
| 
 | ||||
|       context 'when create_runner_workflow_for_namespace is enabled', :js do | ||||
|       context 'when user views runners page', :js do | ||||
|         before do | ||||
|           stub_feature_flags(create_runner_workflow_for_namespace: [project.namespace]) | ||||
| 
 | ||||
|           visit project_runners_path(project) | ||||
|         end | ||||
| 
 | ||||
|  | @ -38,58 +32,18 @@ RSpec.describe 'Runners', feature_category: :runner_fleet do | |||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'when user views new runner page' do | ||||
|         context 'when create_runner_workflow_for_namespace is enabled', :js do | ||||
|           before do | ||||
|             stub_feature_flags(create_runner_workflow_for_namespace: [project.namespace]) | ||||
| 
 | ||||
|             visit new_project_runner_path(project) | ||||
|           end | ||||
| 
 | ||||
|           it_behaves_like 'creates runner and shows register page' do | ||||
|             let(:register_path_pattern) { register_project_runner_path(project, '.*') } | ||||
|           end | ||||
| 
 | ||||
|           it 'shows the locked field' do | ||||
|             expect(page).to have_selector('input[type="checkbox"][name="locked"]') | ||||
|             expect(page).to have_content(_('Lock to current projects')) | ||||
|           end | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'when create_runner_workflow_for_namespace is disabled' do | ||||
|       context 'when user views new runner page', :js do | ||||
|         before do | ||||
|           stub_feature_flags(create_runner_workflow_for_namespace: false) | ||||
|           visit new_project_runner_path(project) | ||||
|         end | ||||
| 
 | ||||
|         it 'user can see a link with instructions on how to install GitLab Runner' do | ||||
|           visit project_runners_path(project) | ||||
| 
 | ||||
|           expect(page).to have_link('Install GitLab Runner and ensure it\'s running.', href: "https://docs.gitlab.com/runner/install/") | ||||
|         it_behaves_like 'creates runner and shows register page' do | ||||
|           let(:register_path_pattern) { register_project_runner_path(project, '.*') } | ||||
|         end | ||||
| 
 | ||||
|         describe 'runners registration token' do | ||||
|           let!(:token) { project.runners_token } | ||||
| 
 | ||||
|           before do | ||||
|             visit project_runners_path(project) | ||||
|           end | ||||
| 
 | ||||
|           it 'has a registration token' do | ||||
|             expect(page.find('#registration_token')).to have_content(token) | ||||
|           end | ||||
| 
 | ||||
|           describe 'reload registration token' do | ||||
|             let(:page_token) { find('#registration_token').text } | ||||
| 
 | ||||
|             before do | ||||
|               click_link 'Reset registration token' | ||||
|             end | ||||
| 
 | ||||
|             it 'changes registration token' do | ||||
|               expect(page_token).not_to eq token | ||||
|             end | ||||
|           end | ||||
|         it 'shows the locked field' do | ||||
|           expect(page).to have_selector('input[type="checkbox"][name="locked"]') | ||||
|           expect(page).to have_content(_('Lock to current projects')) | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|  |  | |||
|  | @ -1,79 +0,0 @@ | |||
| import ConnectionMonitor from '~/actioncable_connection_monitor'; | ||||
| 
 | ||||
| describe('ConnectionMonitor', () => { | ||||
|   let monitor; | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     monitor = new ConnectionMonitor({}); | ||||
|   }); | ||||
| 
 | ||||
|   describe('#getPollInterval', () => { | ||||
|     beforeEach(() => { | ||||
|       Math.originalRandom = Math.random; | ||||
|     }); | ||||
|     afterEach(() => { | ||||
|       Math.random = Math.originalRandom; | ||||
|     }); | ||||
| 
 | ||||
|     const { staleThreshold, reconnectionBackoffRate } = ConnectionMonitor; | ||||
|     const backoffFactor = 1 + reconnectionBackoffRate; | ||||
|     const ms = 1000; | ||||
| 
 | ||||
|     it('uses exponential backoff', () => { | ||||
|       Math.random = () => 0; | ||||
| 
 | ||||
|       monitor.reconnectAttempts = 0; | ||||
|       expect(monitor.getPollInterval()).toEqual(staleThreshold * ms); | ||||
| 
 | ||||
|       monitor.reconnectAttempts = 1; | ||||
|       expect(monitor.getPollInterval()).toEqual(staleThreshold * backoffFactor * ms); | ||||
| 
 | ||||
|       monitor.reconnectAttempts = 2; | ||||
|       expect(monitor.getPollInterval()).toEqual( | ||||
|         staleThreshold * backoffFactor * backoffFactor * ms, | ||||
|       ); | ||||
|     }); | ||||
| 
 | ||||
|     it('caps exponential backoff after some number of reconnection attempts', () => { | ||||
|       Math.random = () => 0; | ||||
|       monitor.reconnectAttempts = 42; | ||||
|       const cappedPollInterval = monitor.getPollInterval(); | ||||
| 
 | ||||
|       monitor.reconnectAttempts = 9001; | ||||
|       expect(monitor.getPollInterval()).toEqual(cappedPollInterval); | ||||
|     }); | ||||
| 
 | ||||
|     it('uses 100% jitter when 0 reconnection attempts', () => { | ||||
|       Math.random = () => 0; | ||||
|       expect(monitor.getPollInterval()).toEqual(staleThreshold * ms); | ||||
| 
 | ||||
|       Math.random = () => 0.5; | ||||
|       expect(monitor.getPollInterval()).toEqual(staleThreshold * 1.5 * ms); | ||||
|     }); | ||||
| 
 | ||||
|     it('uses reconnectionBackoffRate for jitter when >0 reconnection attempts', () => { | ||||
|       monitor.reconnectAttempts = 1; | ||||
| 
 | ||||
|       Math.random = () => 0.25; | ||||
|       expect(monitor.getPollInterval()).toEqual( | ||||
|         staleThreshold * backoffFactor * (1 + reconnectionBackoffRate * 0.25) * ms, | ||||
|       ); | ||||
| 
 | ||||
|       Math.random = () => 0.5; | ||||
|       expect(monitor.getPollInterval()).toEqual( | ||||
|         staleThreshold * backoffFactor * (1 + reconnectionBackoffRate * 0.5) * ms, | ||||
|       ); | ||||
|     }); | ||||
| 
 | ||||
|     it('applies jitter after capped exponential backoff', () => { | ||||
|       monitor.reconnectAttempts = 9001; | ||||
| 
 | ||||
|       Math.random = () => 0; | ||||
|       const withoutJitter = monitor.getPollInterval(); | ||||
|       Math.random = () => 0.5; | ||||
|       const withJitter = monitor.getPollInterval(); | ||||
| 
 | ||||
|       expect(withJitter).toBeGreaterThan(withoutJitter); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | @ -41,7 +41,9 @@ describe('RegistrationDropdown', () => { | |||
|   const findTokenDropdownItem = () => wrapper.findComponent(GlDropdownForm); | ||||
|   const findRegistrationToken = () => wrapper.findComponent(RegistrationToken); | ||||
|   const findRegistrationTokenInput = () => | ||||
|     wrapper.findByLabelText(RegistrationToken.i18n.registrationToken); | ||||
|     wrapper.findByLabelText( | ||||
|       `${RegistrationToken.i18n.registrationToken} ${RegistrationDropdown.i18n.supportForRegistrationTokensDeprecated}`, | ||||
|     ); | ||||
|   const findTokenResetDropdownItem = () => | ||||
|     wrapper.findComponent(RegistrationTokenResetDropdownItem); | ||||
|   const findModal = () => wrapper.findComponent(GlModal); | ||||
|  | @ -107,12 +109,12 @@ describe('RegistrationDropdown', () => { | |||
|     createComponent(); | ||||
| 
 | ||||
|     expect(findDropdown().props()).toMatchObject({ | ||||
|       category: 'primary', | ||||
|       variant: 'confirm', | ||||
|       category: 'tertiary', | ||||
|       variant: 'default', | ||||
|     }); | ||||
| 
 | ||||
|     expect(findDropdown().attributes()).toMatchObject({ | ||||
|       toggleclass: '', | ||||
|       toggleclass: 'gl-px-3!', | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|  | @ -217,14 +219,9 @@ describe('RegistrationDropdown', () => { | |||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe.each([ | ||||
|     { createRunnerWorkflowForAdmin: true }, | ||||
|     { createRunnerWorkflowForNamespace: true }, | ||||
|   ])('When showing a "deprecated" warning', (glFeatures) => { | ||||
|   describe('When showing a "deprecated" warning', () => { | ||||
|     it('passes deprecated variant props and attributes to dropdown', () => { | ||||
|       createComponent({ | ||||
|         provide: { glFeatures }, | ||||
|       }); | ||||
|       createComponent(); | ||||
| 
 | ||||
|       expect(findDropdown().props()).toMatchObject({ | ||||
|         category: 'tertiary', | ||||
|  | @ -249,12 +246,7 @@ describe('RegistrationDropdown', () => { | |||
|     }); | ||||
| 
 | ||||
|     it('shows warning text', () => { | ||||
|       createComponent( | ||||
|         { | ||||
|           provide: { glFeatures }, | ||||
|         }, | ||||
|         mountExtended, | ||||
|       ); | ||||
|       createComponent({}, mountExtended); | ||||
| 
 | ||||
|       const text = wrapper.findByText(s__('Runners|Support for registration tokens is deprecated')); | ||||
| 
 | ||||
|  | @ -262,12 +254,7 @@ describe('RegistrationDropdown', () => { | |||
|     }); | ||||
| 
 | ||||
|     it('button shows ellipsis icon', () => { | ||||
|       createComponent( | ||||
|         { | ||||
|           provide: { glFeatures }, | ||||
|         }, | ||||
|         mountExtended, | ||||
|       ); | ||||
|       createComponent({}, mountExtended); | ||||
| 
 | ||||
|       expect(findDropdownBtn().findComponent(GlIcon).props('name')).toBe('ellipsis_v'); | ||||
|       expect(findDropdownBtn().findAllComponents(GlIcon)).toHaveLength(1); | ||||
|  |  | |||
|  | @ -10,7 +10,6 @@ import { | |||
|   I18N_CREATE_RUNNER_LINK, | ||||
|   I18N_STILL_USING_REGISTRATION_TOKENS, | ||||
|   I18N_CONTACT_ADMIN_TO_REGISTER, | ||||
|   I18N_FOLLOW_REGISTRATION_INSTRUCTIONS, | ||||
|   I18N_NO_RESULTS, | ||||
|   I18N_EDIT_YOUR_SEARCH, | ||||
| } from '~/ci/runner/constants'; | ||||
|  | @ -59,137 +58,85 @@ describe('RunnerListEmptyState', () => { | |||
|   }); | ||||
| 
 | ||||
|   describe('when search is not filtered', () => { | ||||
|     describe.each([ | ||||
|       { createRunnerWorkflowForAdmin: true }, | ||||
|       { createRunnerWorkflowForNamespace: true }, | ||||
|     ])('when createRunnerWorkflow is enabled by %o', (currentGlFeatures) => { | ||||
|       beforeEach(() => { | ||||
|         glFeatures = currentGlFeatures; | ||||
|       }); | ||||
| 
 | ||||
|       describe.each` | ||||
|         newRunnerPath        | registrationToken        | expectedMessages | ||||
|         ${mockNewRunnerPath} | ${mockRegistrationToken} | ${[I18N_CREATE_RUNNER_LINK, I18N_STILL_USING_REGISTRATION_TOKENS]} | ||||
|         ${mockNewRunnerPath} | ${null}                  | ${[I18N_CREATE_RUNNER_LINK]} | ||||
|         ${null}              | ${mockRegistrationToken} | ${[I18N_STILL_USING_REGISTRATION_TOKENS]} | ||||
|         ${null}              | ${null}                  | ${[I18N_CONTACT_ADMIN_TO_REGISTER]} | ||||
|       `(
 | ||||
|         'when newRunnerPath is $newRunnerPath and registrationToken is $registrationToken', | ||||
|         ({ newRunnerPath, registrationToken, expectedMessages }) => { | ||||
|           beforeEach(() => { | ||||
|             createComponent({ | ||||
|               props: { | ||||
|                 newRunnerPath, | ||||
|                 registrationToken, | ||||
|               }, | ||||
|             }); | ||||
|           }); | ||||
| 
 | ||||
|           it('shows title', () => { | ||||
|             expectTitleToBe(I18N_GET_STARTED); | ||||
|           }); | ||||
| 
 | ||||
|           it('renders an illustration', () => { | ||||
|             expect(findEmptyState().props('svgPath')).toBe(EMPTY_STATE_SVG_URL); | ||||
|           }); | ||||
| 
 | ||||
|           it(`shows description: "${expectedMessages.join(' ')}"`, () => { | ||||
|             expectDescriptionToBe([I18N_RUNNERS_ARE_AGENTS, ...expectedMessages]); | ||||
|           }); | ||||
|         }, | ||||
|       ); | ||||
| 
 | ||||
|       describe('with newRunnerPath and registration token', () => { | ||||
|     describe.each` | ||||
|       newRunnerPath        | registrationToken        | expectedMessages | ||||
|       ${mockNewRunnerPath} | ${mockRegistrationToken} | ${[I18N_CREATE_RUNNER_LINK, I18N_STILL_USING_REGISTRATION_TOKENS]} | ||||
|       ${mockNewRunnerPath} | ${null}                  | ${[I18N_CREATE_RUNNER_LINK]} | ||||
|       ${null}              | ${mockRegistrationToken} | ${[I18N_STILL_USING_REGISTRATION_TOKENS]} | ||||
|       ${null}              | ${null}                  | ${[I18N_CONTACT_ADMIN_TO_REGISTER]} | ||||
|     `(
 | ||||
|       'when newRunnerPath is $newRunnerPath and registrationToken is $registrationToken', | ||||
|       ({ newRunnerPath, registrationToken, expectedMessages }) => { | ||||
|         beforeEach(() => { | ||||
|           createComponent({ | ||||
|             props: { | ||||
|               registrationToken: mockRegistrationToken, | ||||
|               newRunnerPath: mockNewRunnerPath, | ||||
|               newRunnerPath, | ||||
|               registrationToken, | ||||
|             }, | ||||
|           }); | ||||
|         }); | ||||
| 
 | ||||
|         it('shows links to the new runner page and registration instructions', () => { | ||||
|           expect(findLinks().at(0).attributes('href')).toBe(mockNewRunnerPath); | ||||
| 
 | ||||
|           const { value } = getBinding(findLinks().at(1).element, 'gl-modal'); | ||||
|           expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); | ||||
|         }); | ||||
|       }); | ||||
| 
 | ||||
|       describe('with newRunnerPath and no registration token', () => { | ||||
|         beforeEach(() => { | ||||
|           createComponent({ | ||||
|             props: { | ||||
|               registrationToken: mockRegistrationToken, | ||||
|               newRunnerPath: null, | ||||
|             }, | ||||
|           }); | ||||
|         }); | ||||
| 
 | ||||
|         it('opens a runner registration instructions modal with a link', () => { | ||||
|           const { value } = getBinding(findLink().element, 'gl-modal'); | ||||
|           expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); | ||||
|         }); | ||||
|       }); | ||||
| 
 | ||||
|       describe('with no newRunnerPath nor registration token', () => { | ||||
|         beforeEach(() => { | ||||
|           createComponent({ | ||||
|             props: { | ||||
|               registrationToken: null, | ||||
|               newRunnerPath: null, | ||||
|             }, | ||||
|           }); | ||||
|         }); | ||||
| 
 | ||||
|         it('has no link', () => { | ||||
|           expect(findLink().exists()).toBe(false); | ||||
|         }); | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('when createRunnerWorkflow is disabled', () => { | ||||
|       describe('when there is a registration token', () => { | ||||
|         beforeEach(() => { | ||||
|           createComponent({ | ||||
|             props: { | ||||
|               registrationToken: mockRegistrationToken, | ||||
|             }, | ||||
|           }); | ||||
|         it('shows title', () => { | ||||
|           expectTitleToBe(I18N_GET_STARTED); | ||||
|         }); | ||||
| 
 | ||||
|         it('renders an illustration', () => { | ||||
|           expect(findEmptyState().props('svgPath')).toBe(EMPTY_STATE_SVG_URL); | ||||
|         }); | ||||
| 
 | ||||
|         it('opens a runner registration instructions modal with a link', () => { | ||||
|           const { value } = getBinding(findLink().element, 'gl-modal'); | ||||
|           expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); | ||||
|         it(`shows description: "${expectedMessages.join(' ')}"`, () => { | ||||
|           expectDescriptionToBe([I18N_RUNNERS_ARE_AGENTS, ...expectedMessages]); | ||||
|         }); | ||||
|       }, | ||||
|     ); | ||||
| 
 | ||||
|         it('displays text with registration instructions', () => { | ||||
|           expectTitleToBe(I18N_GET_STARTED); | ||||
| 
 | ||||
|           expectDescriptionToBe([I18N_RUNNERS_ARE_AGENTS, I18N_FOLLOW_REGISTRATION_INSTRUCTIONS]); | ||||
|     describe('with newRunnerPath and registration token', () => { | ||||
|       beforeEach(() => { | ||||
|         createComponent({ | ||||
|           props: { | ||||
|             registrationToken: mockRegistrationToken, | ||||
|             newRunnerPath: mockNewRunnerPath, | ||||
|           }, | ||||
|         }); | ||||
|       }); | ||||
| 
 | ||||
|       describe('when there is no registration token', () => { | ||||
|         beforeEach(() => { | ||||
|           createComponent({ props: { registrationToken: null } }); | ||||
|         }); | ||||
|       it('shows links to the new runner page and registration instructions', () => { | ||||
|         expect(findLinks().at(0).attributes('href')).toBe(mockNewRunnerPath); | ||||
| 
 | ||||
|         it('displays "contact admin" text', () => { | ||||
|           expectTitleToBe(I18N_GET_STARTED); | ||||
|         const { value } = getBinding(findLinks().at(1).element, 'gl-modal'); | ||||
|         expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|           expectDescriptionToBe([I18N_RUNNERS_ARE_AGENTS, I18N_CONTACT_ADMIN_TO_REGISTER]); | ||||
|     describe('with newRunnerPath and no registration token', () => { | ||||
|       beforeEach(() => { | ||||
|         createComponent({ | ||||
|           props: { | ||||
|             registrationToken: mockRegistrationToken, | ||||
|             newRunnerPath: null, | ||||
|           }, | ||||
|         }); | ||||
|       }); | ||||
| 
 | ||||
|         it('has no registration instructions link', () => { | ||||
|           expect(findLink().exists()).toBe(false); | ||||
|       it('opens a runner registration instructions modal with a link', () => { | ||||
|         const { value } = getBinding(findLink().element, 'gl-modal'); | ||||
|         expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('with no newRunnerPath nor registration token', () => { | ||||
|       beforeEach(() => { | ||||
|         createComponent({ | ||||
|           props: { | ||||
|             registrationToken: null, | ||||
|             newRunnerPath: null, | ||||
|           }, | ||||
|         }); | ||||
|       }); | ||||
| 
 | ||||
|       it('has no link', () => { | ||||
|         expect(findLink().exists()).toBe(false); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -483,35 +483,15 @@ describe('GroupRunnersApp', () => { | |||
|       expect(findRegistrationDropdown().exists()).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     it('when create_runner_workflow_for_namespace is enabled', () => { | ||||
|     it('shows the create runner button', () => { | ||||
|       createComponent({ | ||||
|         props: { | ||||
|           newRunnerPath, | ||||
|         }, | ||||
|         provide: { | ||||
|           glFeatures: { | ||||
|             createRunnerWorkflowForNamespace: true, | ||||
|           }, | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|       expect(findNewRunnerBtn().attributes('href')).toBe(newRunnerPath); | ||||
|     }); | ||||
| 
 | ||||
|     it('when create_runner_workflow_for_namespace is disabled', () => { | ||||
|       createComponent({ | ||||
|         props: { | ||||
|           newRunnerPath, | ||||
|         }, | ||||
|         provide: { | ||||
|           glFeatures: { | ||||
|             createRunnerWorkflowForNamespace: false, | ||||
|           }, | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|       expect(findNewRunnerBtn().exists()).toBe(false); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when user has no permission to register group runner', () => { | ||||
|  | @ -524,16 +504,11 @@ describe('GroupRunnersApp', () => { | |||
|       expect(findRegistrationDropdown().exists()).toBe(false); | ||||
|     }); | ||||
| 
 | ||||
|     it('when create_runner_workflow_for_namespace is enabled', () => { | ||||
|     it('shows the create runner button', () => { | ||||
|       createComponent({ | ||||
|         props: { | ||||
|           newRunnerPath: null, | ||||
|         }, | ||||
|         provide: { | ||||
|           glFeatures: { | ||||
|             createRunnerWorkflowForNamespace: true, | ||||
|           }, | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|       expect(findNewRunnerBtn().exists()).toBe(false); | ||||
|  |  | |||
|  | @ -3,6 +3,8 @@ import { shallowMount } from '@vue/test-utils'; | |||
| import TaskListItemActions from '~/issues/show/components/task_list_item_actions.vue'; | ||||
| import eventHub from '~/issues/show/event_hub'; | ||||
| 
 | ||||
| jest.mock('~/issues/show/event_hub'); | ||||
| 
 | ||||
| describe('TaskListItemActions component', () => { | ||||
|   let wrapper; | ||||
| 
 | ||||
|  | @ -37,16 +39,12 @@ describe('TaskListItemActions component', () => { | |||
|   }); | ||||
| 
 | ||||
|   it('emits event when `Convert to task` dropdown item is clicked', () => { | ||||
|     jest.spyOn(eventHub, '$emit'); | ||||
| 
 | ||||
|     findConvertToTaskItem().vm.$emit('action'); | ||||
| 
 | ||||
|     expect(eventHub.$emit).toHaveBeenCalledWith('convert-task-list-item', '3:1-3:10'); | ||||
|   }); | ||||
| 
 | ||||
|   it('emits event when `Delete` dropdown item is clicked', () => { | ||||
|     jest.spyOn(eventHub, '$emit'); | ||||
| 
 | ||||
|     findDeleteItem().vm.$emit('action'); | ||||
| 
 | ||||
|     expect(eventHub.$emit).toHaveBeenCalledWith('delete-task-list-item', '3:1-3:10'); | ||||
|  |  | |||
|  | @ -1,38 +1,37 @@ | |||
| import { shallowMount } from '@vue/test-utils'; | ||||
| import { nextTick } from 'vue'; | ||||
| import { TEST_HOST } from 'helpers/test_constants'; | ||||
| import GfmAutoComplete from '~/gfm_auto_complete'; | ||||
| import { TYPE_ISSUE } from '~/issues/constants'; | ||||
| import RelatedIssuableInput from '~/related_issues/components/related_issuable_input.vue'; | ||||
| import { PathIdSeparator } from '~/related_issues/constants'; | ||||
| 
 | ||||
| jest.mock('ee_else_ce/gfm_auto_complete', () => { | ||||
|   return function gfmAutoComplete() { | ||||
|     return { | ||||
|       constructor() {}, | ||||
|       setup() {}, | ||||
|     }; | ||||
|   }; | ||||
| }); | ||||
| jest.mock('~/gfm_auto_complete'); | ||||
| 
 | ||||
| describe('RelatedIssuableInput', () => { | ||||
|   let propsData; | ||||
|   let wrapper; | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     propsData = { | ||||
|       inputValue: '', | ||||
|       references: [], | ||||
|       pathIdSeparator: PathIdSeparator.Issue, | ||||
|       issuableType: TYPE_ISSUE, | ||||
|       autoCompleteSources: { | ||||
|         issues: `${TEST_HOST}/h5bp/html5-boilerplate/-/autocomplete_sources/issues`, | ||||
|   const autoCompleteSources = { | ||||
|     issues: `${TEST_HOST}/h5bp/html5-boilerplate/-/autocomplete_sources/issues`, | ||||
|   }; | ||||
| 
 | ||||
|   const mountComponent = (props = {}) => { | ||||
|     wrapper = shallowMount(RelatedIssuableInput, { | ||||
|       propsData: { | ||||
|         inputValue: '', | ||||
|         references: [], | ||||
|         pathIdSeparator: PathIdSeparator.Issue, | ||||
|         issuableType: TYPE_ISSUE, | ||||
|         autoCompleteSources, | ||||
|         ...props, | ||||
|       }, | ||||
|     }; | ||||
|   }); | ||||
|       attachTo: document.body, | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   describe('autocomplete', () => { | ||||
|     describe('with autoCompleteSources', () => { | ||||
|       it('shows placeholder text', () => { | ||||
|         const wrapper = shallowMount(RelatedIssuableInput, { propsData }); | ||||
|         mountComponent(); | ||||
| 
 | ||||
|         expect(wrapper.findComponent({ ref: 'input' }).element.placeholder).toBe( | ||||
|           'Paste issue link or <#issue id>', | ||||
|  | @ -40,51 +39,32 @@ describe('RelatedIssuableInput', () => { | |||
|       }); | ||||
| 
 | ||||
|       it('has GfmAutoComplete', () => { | ||||
|         const wrapper = shallowMount(RelatedIssuableInput, { propsData }); | ||||
|         mountComponent(); | ||||
| 
 | ||||
|         expect(wrapper.vm.gfmAutoComplete).toBeDefined(); | ||||
|         expect(GfmAutoComplete).toHaveBeenCalledWith(autoCompleteSources); | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('with no autoCompleteSources', () => { | ||||
|       it('shows placeholder text', () => { | ||||
|         const wrapper = shallowMount(RelatedIssuableInput, { | ||||
|           propsData: { | ||||
|             ...propsData, | ||||
|             references: ['!1', '!2'], | ||||
|           }, | ||||
|         }); | ||||
|         mountComponent({ references: ['!1', '!2'] }); | ||||
| 
 | ||||
|         expect(wrapper.findComponent({ ref: 'input' }).element.value).toBe(''); | ||||
|       }); | ||||
| 
 | ||||
|       it('does not have GfmAutoComplete', () => { | ||||
|         const wrapper = shallowMount(RelatedIssuableInput, { | ||||
|           propsData: { | ||||
|             ...propsData, | ||||
|             autoCompleteSources: {}, | ||||
|           }, | ||||
|         }); | ||||
|         mountComponent({ autoCompleteSources: {} }); | ||||
| 
 | ||||
|         expect(wrapper.vm.gfmAutoComplete).not.toBeDefined(); | ||||
|         expect(GfmAutoComplete).not.toHaveBeenCalled(); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('focus', () => { | ||||
|     it('when clicking anywhere on the input wrapper it should focus the input', async () => { | ||||
|       const wrapper = shallowMount(RelatedIssuableInput, { | ||||
|         propsData: { | ||||
|           ...propsData, | ||||
|           references: ['foo', 'bar'], | ||||
|         }, | ||||
|         // We need to attach to document, so that `document.activeElement` is properly set in jsdom
 | ||||
|         attachTo: document.body, | ||||
|       }); | ||||
|       mountComponent({ references: ['foo', 'bar'] }); | ||||
| 
 | ||||
|       wrapper.find('li').trigger('click'); | ||||
| 
 | ||||
|       await nextTick(); | ||||
|       await wrapper.find('li').trigger('click'); | ||||
| 
 | ||||
|       expect(document.activeElement).toBe(wrapper.findComponent({ ref: 'input' }).element); | ||||
|     }); | ||||
|  | @ -92,11 +72,7 @@ describe('RelatedIssuableInput', () => { | |||
| 
 | ||||
|   describe('when filling in the input', () => { | ||||
|     it('emits addIssuableFormInput with data', () => { | ||||
|       const wrapper = shallowMount(RelatedIssuableInput, { | ||||
|         propsData, | ||||
|       }); | ||||
| 
 | ||||
|       wrapper.vm.$emit = jest.fn(); | ||||
|       mountComponent(); | ||||
| 
 | ||||
|       const newInputValue = 'filling in things'; | ||||
|       const untouchedRawReferences = newInputValue.trim().split(/\s/); | ||||
|  | @ -108,12 +84,16 @@ describe('RelatedIssuableInput', () => { | |||
|       input.element.selectionEnd = newInputValue.length; | ||||
|       input.trigger('input'); | ||||
| 
 | ||||
|       expect(wrapper.vm.$emit).toHaveBeenCalledWith('addIssuableFormInput', { | ||||
|         newValue: newInputValue, | ||||
|         caretPos: newInputValue.length, | ||||
|         untouchedRawReferences, | ||||
|         touchedReference, | ||||
|       }); | ||||
|       expect(wrapper.emitted('addIssuableFormInput')).toEqual([ | ||||
|         [ | ||||
|           { | ||||
|             newValue: newInputValue, | ||||
|             caretPos: newInputValue.length, | ||||
|             untouchedRawReferences, | ||||
|             touchedReference, | ||||
|           }, | ||||
|         ], | ||||
|       ]); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -1,3 +1,4 @@ | |||
| import { GlIcon } from '@gitlab/ui'; | ||||
| import { shallowMount } from '@vue/test-utils'; | ||||
| import Vue, { nextTick } from 'vue'; | ||||
| import Vuex from 'vuex'; | ||||
|  | @ -154,6 +155,13 @@ describe('IssuableLockForm', () => { | |||
|               expect(tooltip).toBeDefined(); | ||||
|               expect(tooltip.value.title).toBe(isLocked ? 'Locked' : 'Unlocked'); | ||||
|             }); | ||||
| 
 | ||||
|             it('renders lock icon', () => { | ||||
|               const icon = findSidebarCollapseIcon().findComponent(GlIcon).props('name'); | ||||
|               const expected = isLocked ? 'lock' : 'lock-open'; | ||||
| 
 | ||||
|               expect(icon).toBe(expected); | ||||
|             }); | ||||
|           }); | ||||
|         }); | ||||
|       }); | ||||
|  |  | |||
|  | @ -148,3 +148,16 @@ export const getMrTimelogsQueryResponse = { | |||
|     }, | ||||
|   }, | ||||
| }; | ||||
| 
 | ||||
| export const deleteTimelogMutationResponse = { | ||||
|   data: { | ||||
|     timelogDelete: { | ||||
|       errors: [], | ||||
|       timelog: { | ||||
|         id: 'gid://gitlab/Issue/148', | ||||
|         issue: {}, | ||||
|         mergeRequest: {}, | ||||
|       }, | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
|  |  | |||
|  | @ -12,6 +12,7 @@ import getIssueTimelogsQuery from '~/sidebar/queries/get_issue_timelogs.query.gr | |||
| import getMrTimelogsQuery from '~/sidebar/queries/get_mr_timelogs.query.graphql'; | ||||
| import deleteTimelogMutation from '~/sidebar/queries/delete_timelog.mutation.graphql'; | ||||
| import { | ||||
|   deleteTimelogMutationResponse, | ||||
|   getIssueTimelogsQueryResponse, | ||||
|   getMrTimelogsQueryResponse, | ||||
|   timelogToRemoveId, | ||||
|  | @ -22,7 +23,7 @@ jest.mock('~/alert'); | |||
| describe('Issuable Time Tracking Report', () => { | ||||
|   Vue.use(VueApollo); | ||||
|   let wrapper; | ||||
|   let fakeApollo; | ||||
| 
 | ||||
|   const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); | ||||
|   const findDeleteButton = () => wrapper.findByTestId('deleteButton'); | ||||
|   const successIssueQueryHandler = jest.fn().mockResolvedValue(getIssueTimelogsQueryResponse); | ||||
|  | @ -30,30 +31,27 @@ describe('Issuable Time Tracking Report', () => { | |||
| 
 | ||||
|   const mountComponent = ({ | ||||
|     queryHandler = successIssueQueryHandler, | ||||
|     mutationHandler, | ||||
|     issuableType = 'issue', | ||||
|     mountFunction = shallowMount, | ||||
|     limitToHours = false, | ||||
|   } = {}) => { | ||||
|     fakeApollo = createMockApollo([ | ||||
|       [getIssueTimelogsQuery, queryHandler], | ||||
|       [getMrTimelogsQuery, queryHandler], | ||||
|     ]); | ||||
|     wrapper = extendedWrapper( | ||||
|       mountFunction(Report, { | ||||
|         apolloProvider: createMockApollo([ | ||||
|           [getIssueTimelogsQuery, queryHandler], | ||||
|           [getMrTimelogsQuery, queryHandler], | ||||
|           [deleteTimelogMutation, mutationHandler], | ||||
|         ]), | ||||
|         provide: { | ||||
|           issuableId: 1, | ||||
|           issuableType, | ||||
|         }, | ||||
|         propsData: { limitToHours, issuableId: '1' }, | ||||
|         apolloProvider: fakeApollo, | ||||
|       }), | ||||
|     ); | ||||
|   }; | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     fakeApollo = null; | ||||
|   }); | ||||
| 
 | ||||
|   it('should render loading spinner', () => { | ||||
|     mountComponent(); | ||||
| 
 | ||||
|  | @ -135,50 +133,27 @@ describe('Issuable Time Tracking Report', () => { | |||
|   }); | ||||
| 
 | ||||
|   describe('when clicking on the delete timelog button', () => { | ||||
|     beforeEach(() => { | ||||
|       mountComponent({ mountFunction: mount }); | ||||
|     }); | ||||
| 
 | ||||
|     it('calls `$apollo.mutate` with deleteTimelogMutation mutation and removes the row', async () => { | ||||
|       const mutateSpy = jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ | ||||
|         data: { | ||||
|           timelogDelete: { | ||||
|             errors: [], | ||||
|           }, | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|       const mutateSpy = jest.fn().mockResolvedValue(deleteTimelogMutationResponse); | ||||
|       mountComponent({ mutationHandler: mutateSpy, mountFunction: mount }); | ||||
|       await waitForPromises(); | ||||
| 
 | ||||
|       await findDeleteButton().trigger('click'); | ||||
|       await waitForPromises(); | ||||
| 
 | ||||
|       expect(createAlert).not.toHaveBeenCalled(); | ||||
|       expect(mutateSpy).toHaveBeenCalledWith({ | ||||
|         mutation: deleteTimelogMutation, | ||||
|         variables: { | ||||
|           input: { | ||||
|             id: timelogToRemoveId, | ||||
|           }, | ||||
|         }, | ||||
|       }); | ||||
|       expect(mutateSpy).toHaveBeenCalledWith({ input: { id: timelogToRemoveId } }); | ||||
|     }); | ||||
| 
 | ||||
|     it('calls `createAlert` with errorMessage and does not remove the row on promise reject', async () => { | ||||
|       const mutateSpy = jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({}); | ||||
| 
 | ||||
|       const mutateSpy = jest.fn().mockRejectedValue({}); | ||||
|       mountComponent({ mutationHandler: mutateSpy, mountFunction: mount }); | ||||
|       await waitForPromises(); | ||||
| 
 | ||||
|       await findDeleteButton().trigger('click'); | ||||
|       await waitForPromises(); | ||||
| 
 | ||||
|       expect(mutateSpy).toHaveBeenCalledWith({ | ||||
|         mutation: deleteTimelogMutation, | ||||
|         variables: { | ||||
|           input: { | ||||
|             id: timelogToRemoveId, | ||||
|           }, | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|       expect(mutateSpy).toHaveBeenCalledWith({ input: { id: timelogToRemoveId } }); | ||||
|       expect(createAlert).toHaveBeenCalledWith({ | ||||
|         message: 'An error occurred while removing the timelog.', | ||||
|         captureError: true, | ||||
|  |  | |||
|  | @ -39,7 +39,6 @@ describe('CrmContactToken', () => { | |||
|   Vue.use(VueApollo); | ||||
| 
 | ||||
|   let wrapper; | ||||
|   let fakeApollo; | ||||
| 
 | ||||
|   const getBaseToken = () => wrapper.findComponent(BaseToken); | ||||
| 
 | ||||
|  | @ -58,9 +57,8 @@ describe('CrmContactToken', () => { | |||
|     listeners = {}, | ||||
|     queryHandler = searchGroupCrmContactsQueryHandler, | ||||
|   } = {}) => { | ||||
|     fakeApollo = createMockApollo([[searchCrmContactsQuery, queryHandler]]); | ||||
| 
 | ||||
|     wrapper = mount(CrmContactToken, { | ||||
|       apolloProvider: createMockApollo([[searchCrmContactsQuery, queryHandler]]), | ||||
|       propsData: { | ||||
|         config, | ||||
|         value, | ||||
|  | @ -75,14 +73,9 @@ describe('CrmContactToken', () => { | |||
|       }, | ||||
|       stubs, | ||||
|       listeners, | ||||
|       apolloProvider: fakeApollo, | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     fakeApollo = null; | ||||
|   }); | ||||
| 
 | ||||
|   describe('methods', () => { | ||||
|     describe('fetchContacts', () => { | ||||
|       describe('for groups', () => { | ||||
|  | @ -160,9 +153,7 @@ describe('CrmContactToken', () => { | |||
|       }); | ||||
| 
 | ||||
|       it('calls `createAlert` with alert error message when request fails', async () => { | ||||
|         mountComponent(); | ||||
| 
 | ||||
|         jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); | ||||
|         mountComponent({ queryHandler: jest.fn().mockRejectedValue({}) }); | ||||
| 
 | ||||
|         getBaseToken().vm.$emit('fetch-suggestions'); | ||||
|         await waitForPromises(); | ||||
|  | @ -173,12 +164,9 @@ describe('CrmContactToken', () => { | |||
|       }); | ||||
| 
 | ||||
|       it('sets `loading` to false when request completes', async () => { | ||||
|         mountComponent(); | ||||
| 
 | ||||
|         jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); | ||||
|         mountComponent({ queryHandler: jest.fn().mockRejectedValue({}) }); | ||||
| 
 | ||||
|         getBaseToken().vm.$emit('fetch-suggestions'); | ||||
| 
 | ||||
|         await waitForPromises(); | ||||
| 
 | ||||
|         expect(getBaseToken().props('suggestionsLoading')).toBe(false); | ||||
|  | @ -195,13 +183,7 @@ describe('CrmContactToken', () => { | |||
|         value: { data: '1' }, | ||||
|       }); | ||||
| 
 | ||||
|       const baseTokenEl = wrapper.findComponent(BaseToken); | ||||
| 
 | ||||
|       expect(baseTokenEl.exists()).toBe(true); | ||||
|       expect(baseTokenEl.props()).toMatchObject({ | ||||
|         suggestions: mockCrmContacts, | ||||
|         getActiveTokenValue: wrapper.vm.getActiveContact, | ||||
|       }); | ||||
|       expect(getBaseToken().props('suggestions')).toEqual(mockCrmContacts); | ||||
|     }); | ||||
| 
 | ||||
|     it.each(mockCrmContacts)('renders token item when value is selected', (contact) => { | ||||
|  | @ -270,12 +252,9 @@ describe('CrmContactToken', () => { | |||
| 
 | ||||
|     it('emits listeners in the base-token', () => { | ||||
|       const mockInput = jest.fn(); | ||||
|       mountComponent({ | ||||
|         listeners: { | ||||
|           input: mockInput, | ||||
|         }, | ||||
|       }); | ||||
|       wrapper.findComponent(BaseToken).vm.$emit('input', [{ data: 'mockData', operator: '=' }]); | ||||
|       mountComponent({ listeners: { input: mockInput } }); | ||||
| 
 | ||||
|       getBaseToken().vm.$emit('input', [{ data: 'mockData', operator: '=' }]); | ||||
| 
 | ||||
|       expect(mockInput).toHaveBeenLastCalledWith([{ data: 'mockData', operator: '=' }]); | ||||
|     }); | ||||
|  |  | |||
|  | @ -39,7 +39,6 @@ describe('CrmOrganizationToken', () => { | |||
|   Vue.use(VueApollo); | ||||
| 
 | ||||
|   let wrapper; | ||||
|   let fakeApollo; | ||||
| 
 | ||||
|   const getBaseToken = () => wrapper.findComponent(BaseToken); | ||||
| 
 | ||||
|  | @ -58,8 +57,8 @@ describe('CrmOrganizationToken', () => { | |||
|     listeners = {}, | ||||
|     queryHandler = searchGroupCrmOrganizationsQueryHandler, | ||||
|   } = {}) => { | ||||
|     fakeApollo = createMockApollo([[searchCrmOrganizationsQuery, queryHandler]]); | ||||
|     wrapper = mount(CrmOrganizationToken, { | ||||
|       apolloProvider: createMockApollo([[searchCrmOrganizationsQuery, queryHandler]]), | ||||
|       propsData: { | ||||
|         config, | ||||
|         value, | ||||
|  | @ -74,14 +73,9 @@ describe('CrmOrganizationToken', () => { | |||
|       }, | ||||
|       stubs, | ||||
|       listeners, | ||||
|       apolloProvider: fakeApollo, | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     fakeApollo = null; | ||||
|   }); | ||||
| 
 | ||||
|   describe('methods', () => { | ||||
|     describe('fetchOrganizations', () => { | ||||
|       describe('for groups', () => { | ||||
|  | @ -159,9 +153,7 @@ describe('CrmOrganizationToken', () => { | |||
|       }); | ||||
| 
 | ||||
|       it('calls `createAlert` when request fails', async () => { | ||||
|         mountComponent(); | ||||
| 
 | ||||
|         jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); | ||||
|         mountComponent({ queryHandler: jest.fn().mockRejectedValue({}) }); | ||||
| 
 | ||||
|         getBaseToken().vm.$emit('fetch-suggestions'); | ||||
|         await waitForPromises(); | ||||
|  | @ -172,9 +164,7 @@ describe('CrmOrganizationToken', () => { | |||
|       }); | ||||
| 
 | ||||
|       it('sets `loading` to false when request completes', async () => { | ||||
|         mountComponent(); | ||||
| 
 | ||||
|         jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); | ||||
|         mountComponent({ queryHandler: jest.fn().mockRejectedValue({}) }); | ||||
| 
 | ||||
|         getBaseToken().vm.$emit('fetch-suggestions'); | ||||
| 
 | ||||
|  | @ -194,13 +184,7 @@ describe('CrmOrganizationToken', () => { | |||
|         value: { data: '1' }, | ||||
|       }); | ||||
| 
 | ||||
|       const baseTokenEl = wrapper.findComponent(BaseToken); | ||||
| 
 | ||||
|       expect(baseTokenEl.exists()).toBe(true); | ||||
|       expect(baseTokenEl.props()).toMatchObject({ | ||||
|         suggestions: mockCrmOrganizations, | ||||
|         getActiveTokenValue: wrapper.vm.getActiveOrganization, | ||||
|       }); | ||||
|       expect(getBaseToken().props('suggestions')).toEqual(mockCrmOrganizations); | ||||
|     }); | ||||
| 
 | ||||
|     it.each(mockCrmOrganizations)('renders token item when value is selected', (organization) => { | ||||
|  | @ -269,12 +253,9 @@ describe('CrmOrganizationToken', () => { | |||
| 
 | ||||
|     it('emits listeners in the base-token', () => { | ||||
|       const mockInput = jest.fn(); | ||||
|       mountComponent({ | ||||
|         listeners: { | ||||
|           input: mockInput, | ||||
|         }, | ||||
|       }); | ||||
|       wrapper.findComponent(BaseToken).vm.$emit('input', [{ data: 'mockData', operator: '=' }]); | ||||
|       mountComponent({ listeners: { input: mockInput } }); | ||||
| 
 | ||||
|       getBaseToken().vm.$emit('input', [{ data: 'mockData', operator: '=' }]); | ||||
| 
 | ||||
|       expect(mockInput).toHaveBeenLastCalledWith([{ data: 'mockData', operator: '=' }]); | ||||
|     }); | ||||
|  |  | |||
|  | @ -90,14 +90,6 @@ describe('RunnerInstructionsModal component', () => { | |||
|       await waitForPromises(); | ||||
|     }); | ||||
| 
 | ||||
|     it('should not show alert', () => { | ||||
|       expect(findAlert().exists()).toBe(false); | ||||
|     }); | ||||
| 
 | ||||
|     it('should not show deprecation alert', () => { | ||||
|       expect(findAlert('warning').exists()).toBe(false); | ||||
|     }); | ||||
| 
 | ||||
|     it('should contain a number of platforms buttons', () => { | ||||
|       expect(runnerPlatformsHandler).toHaveBeenCalledWith({}); | ||||
| 
 | ||||
|  | @ -112,19 +104,8 @@ describe('RunnerInstructionsModal component', () => { | |||
|       expect(architectures).toEqual(mockPlatformList[0].architectures.nodes); | ||||
|     }); | ||||
| 
 | ||||
|     describe.each` | ||||
|       glFeatures                                    | deprecationAlertExists | ||||
|       ${{}}                                         | ${false} | ||||
|       ${{ createRunnerWorkflowForAdmin: true }}     | ${true} | ||||
|       ${{ createRunnerWorkflowForNamespace: true }} | ${true} | ||||
|     `('with features $glFeatures', ({ glFeatures, deprecationAlertExists }) => {
 | ||||
|       beforeEach(() => { | ||||
|         createComponent({ provide: { glFeatures } }); | ||||
|       }); | ||||
| 
 | ||||
|       it(`alert is ${deprecationAlertExists ? 'shown' : 'not shown'}`, () => { | ||||
|         expect(findAlert('warning').exists()).toBe(deprecationAlertExists); | ||||
|       }); | ||||
|     it('alert is shown', () => { | ||||
|       expect(findAlert('warning').exists()).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     describe('when the modal resizes', () => { | ||||
|  |  | |||
|  | @ -1,10 +1,10 @@ | |||
| import { GlSearchBoxByType } from '@gitlab/ui'; | ||||
| import { shallowMount } from '@vue/test-utils'; | ||||
| import { cloneDeep } from 'lodash'; | ||||
| import Vue, { nextTick } from 'vue'; | ||||
| import VueApollo from 'vue-apollo'; | ||||
| import createMockApollo from 'helpers/mock_apollo_helper'; | ||||
| import waitForPromises from 'helpers/wait_for_promises'; | ||||
| import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; | ||||
| import searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphql'; | ||||
| import searchUsersQueryOnMR from '~/graphql_shared/queries/users_search_with_mr_permissions.graphql'; | ||||
| import { TYPE_MERGE_REQUEST } from '~/issues/constants'; | ||||
|  | @ -44,20 +44,20 @@ Vue.use(VueApollo); | |||
| describe('User select dropdown', () => { | ||||
|   let wrapper; | ||||
|   let fakeApollo; | ||||
|   const hideDropdownMock = jest.fn(); | ||||
| 
 | ||||
|   const findSearchField = () => wrapper.findComponent(GlSearchBoxByType); | ||||
|   const findParticipantsLoading = () => wrapper.find('[data-testid="loading-participants"]'); | ||||
|   const findSelectedParticipants = () => wrapper.findAll('[data-testid="selected-participant"]'); | ||||
|   const findParticipantsLoading = () => wrapper.findByTestId('loading-participants'); | ||||
|   const findSelectedParticipants = () => wrapper.findAllByTestId('selected-participant'); | ||||
|   const findSelectedParticipantByIndex = (index) => | ||||
|     findSelectedParticipants().at(index).findComponent(SidebarParticipant); | ||||
|   const findUnselectedParticipants = () => | ||||
|     wrapper.findAll('[data-testid="unselected-participant"]'); | ||||
|   const findUnselectedParticipants = () => wrapper.findAllByTestId('unselected-participant'); | ||||
|   const findUnselectedParticipantByIndex = (index) => | ||||
|     findUnselectedParticipants().at(index).findComponent(SidebarParticipant); | ||||
|   const findCurrentUser = () => wrapper.findAll('[data-testid="current-user"]'); | ||||
|   const findIssuableAuthor = () => wrapper.findAll('[data-testid="issuable-author"]'); | ||||
|   const findUnassignLink = () => wrapper.find('[data-testid="unassign"]'); | ||||
|   const findEmptySearchResults = () => wrapper.find('[data-testid="empty-results"]'); | ||||
|   const findCurrentUser = () => wrapper.findAllByTestId('current-user'); | ||||
|   const findIssuableAuthor = () => wrapper.findAllByTestId('issuable-author'); | ||||
|   const findUnassignLink = () => wrapper.findByTestId('unassign'); | ||||
|   const findEmptySearchResults = () => wrapper.findAllByTestId('empty-results'); | ||||
| 
 | ||||
|   const searchQueryHandlerSuccess = jest.fn().mockResolvedValue(projectMembersResponse); | ||||
|   const participantsQueryHandlerSuccess = jest.fn().mockResolvedValue(participantsQueryResponse); | ||||
|  | @ -72,7 +72,7 @@ describe('User select dropdown', () => { | |||
|       [searchUsersQueryOnMR, jest.fn().mockResolvedValue(searchResponseOnMR)], | ||||
|       [getIssueParticipantsQuery, participantsQueryHandler], | ||||
|     ]); | ||||
|     wrapper = shallowMount(UserSelect, { | ||||
|     wrapper = shallowMountExtended(UserSelect, { | ||||
|       apolloProvider: fakeApollo, | ||||
|       propsData: { | ||||
|         headerText: 'test', | ||||
|  | @ -97,7 +97,7 @@ describe('User select dropdown', () => { | |||
|             </div> | ||||
|           `,
 | ||||
|           methods: { | ||||
|             hide: jest.fn(), | ||||
|             hide: hideDropdownMock, | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|  | @ -106,6 +106,7 @@ describe('User select dropdown', () => { | |||
| 
 | ||||
|   afterEach(() => { | ||||
|     fakeApollo = null; | ||||
|     hideDropdownMock.mockClear(); | ||||
|   }); | ||||
| 
 | ||||
|   it('renders a loading spinner if participants are loading', () => { | ||||
|  | @ -290,12 +291,12 @@ describe('User select dropdown', () => { | |||
|           value: [assignee], | ||||
|         }, | ||||
|       }); | ||||
|       wrapper.vm.$refs.dropdown.hide = jest.fn(); | ||||
| 
 | ||||
|       await waitForPromises(); | ||||
| 
 | ||||
|       findUnassignLink().trigger('click'); | ||||
| 
 | ||||
|       expect(wrapper.vm.$refs.dropdown.hide).toHaveBeenCalledTimes(1); | ||||
|       expect(hideDropdownMock).toHaveBeenCalledTimes(1); | ||||
|     }); | ||||
| 
 | ||||
|     it('emits an empty array after unselecting the only selected assignee', async () => { | ||||
|  |  | |||
|  | @ -65,9 +65,7 @@ RSpec.describe Gitlab::MarkdownCache::Redis::Extension, :clean_gitlab_redis_cach | |||
|       Gitlab::Redis::Cache.with do |redis| | ||||
|         expect(redis).to receive(:pipelined).and_call_original | ||||
| 
 | ||||
|         times = Gitlab::Redis::ClusterUtil.cluster?(redis) ? 2 : 1 | ||||
| 
 | ||||
|         expect_next_instances_of(Redis::PipelinedConnection, times) do |pipeline| | ||||
|         expect_next_instance_of(Redis::PipelinedConnection) do |pipeline| | ||||
|           expect(pipeline).to receive(:mapped_hmget).once.and_call_original | ||||
|         end | ||||
|       end | ||||
|  |  | |||
|  | @ -832,6 +832,30 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do | |||
|           subject | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'when with_readonly_pipeline is used' do | ||||
|         it 'calls the default store only' do | ||||
|           expect(primary_store).to receive(:send).and_call_original | ||||
|           expect(secondary_store).not_to receive(:send).and_call_original | ||||
| 
 | ||||
|           multi_store.with_readonly_pipeline { subject } | ||||
|         end | ||||
| 
 | ||||
|         context 'when used in a nested manner' do | ||||
|           subject(:nested_subject) do | ||||
|             multi_store.with_readonly_pipeline do | ||||
|               multi_store.with_readonly_pipeline { subject } | ||||
|             end | ||||
|           end | ||||
| 
 | ||||
|           it 'raises error' do | ||||
|             expect { nested_subject }.to raise_error(Gitlab::Redis::MultiStore::NestedReadonlyPipelineError) | ||||
|             expect { nested_subject }.to raise_error { |e| | ||||
|                                            expect(e.message).to eq('Nested use of with_readonly_pipeline is detected.') | ||||
|                                          } | ||||
|           end | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|  |  | |||
|  | @ -694,59 +694,5 @@ RSpec.describe GlobalPolicy, feature_category: :shared do | |||
| 
 | ||||
|       it { is_expected.to be_disallowed(:create_instance_runner) } | ||||
|     end | ||||
| 
 | ||||
|     context 'create_runner_workflow_for_admin flag disabled' do | ||||
|       before do | ||||
|         stub_feature_flags(create_runner_workflow_for_admin: false) | ||||
|       end | ||||
| 
 | ||||
|       context 'admin' do | ||||
|         let(:current_user) { admin_user } | ||||
| 
 | ||||
|         context 'when admin mode is enabled', :enable_admin_mode do | ||||
|           it { is_expected.to be_disallowed(:create_instance_runner) } | ||||
|         end | ||||
| 
 | ||||
|         context 'when admin mode is disabled' do | ||||
|           it { is_expected.to be_disallowed(:create_instance_runner) } | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'with project_bot' do | ||||
|         let(:current_user) { project_bot } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_instance_runner) } | ||||
|       end | ||||
| 
 | ||||
|       context 'with migration_bot' do | ||||
|         let(:current_user) { migration_bot } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_instance_runner) } | ||||
|       end | ||||
| 
 | ||||
|       context 'with security_bot' do | ||||
|         let(:current_user) { security_bot } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_instance_runner) } | ||||
|       end | ||||
| 
 | ||||
|       context 'with llm_bot' do | ||||
|         let(:current_user) { llm_bot } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_instance_runners) } | ||||
|       end | ||||
| 
 | ||||
|       context 'with regular user' do | ||||
|         let(:current_user) { user } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_instance_runner) } | ||||
|       end | ||||
| 
 | ||||
|       context 'with anonymous' do | ||||
|         let(:current_user) { nil } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_instance_runner) } | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -1483,155 +1483,81 @@ RSpec.describe GroupPolicy, feature_category: :system_access do | |||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'create_runner_workflow_for_namespace flag enabled' do | ||||
|       before do | ||||
|         stub_feature_flags(create_runner_workflow_for_namespace: [group]) | ||||
|       end | ||||
|     context 'admin' do | ||||
|       let(:current_user) { admin } | ||||
| 
 | ||||
|       context 'admin' do | ||||
|         let(:current_user) { admin } | ||||
|       context 'when admin mode is enabled', :enable_admin_mode do | ||||
|         it { is_expected.to be_allowed(:create_runner) } | ||||
| 
 | ||||
|         context 'with specific group runner registration disabled' do | ||||
|           before do | ||||
|             group.runner_registration_enabled = false | ||||
|           end | ||||
| 
 | ||||
|         context 'when admin mode is enabled', :enable_admin_mode do | ||||
|           it { is_expected.to be_allowed(:create_runner) } | ||||
|         end | ||||
| 
 | ||||
|           context 'with specific group runner registration disabled' do | ||||
|             before do | ||||
|               group.runner_registration_enabled = false | ||||
|             end | ||||
|         context 'with group runner registration disabled' do | ||||
|           before do | ||||
|             stub_application_setting(valid_runner_registrars: ['project']) | ||||
|             group.runner_registration_enabled = runner_registration_enabled | ||||
|           end | ||||
| 
 | ||||
|           context 'with specific group runner registration enabled' do | ||||
|             let(:runner_registration_enabled) { true } | ||||
| 
 | ||||
|             it { is_expected.to be_allowed(:create_runner) } | ||||
|           end | ||||
| 
 | ||||
|           context 'with group runner registration disabled' do | ||||
|             before do | ||||
|               stub_application_setting(valid_runner_registrars: ['project']) | ||||
|               group.runner_registration_enabled = runner_registration_enabled | ||||
|             end | ||||
|           context 'with specific group runner registration disabled' do | ||||
|             let(:runner_registration_enabled) { false } | ||||
| 
 | ||||
|             context 'with specific group runner registration enabled' do | ||||
|               let(:runner_registration_enabled) { true } | ||||
| 
 | ||||
|               it { is_expected.to be_allowed(:create_runner) } | ||||
|             end | ||||
| 
 | ||||
|             context 'with specific group runner registration disabled' do | ||||
|               let(:runner_registration_enabled) { false } | ||||
| 
 | ||||
|               it { is_expected.to be_allowed(:create_runner) } | ||||
|             end | ||||
|             it { is_expected.to be_allowed(:create_runner) } | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         context 'when admin mode is disabled' do | ||||
|           it { is_expected.to be_disallowed(:create_runner) } | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'with owner' do | ||||
|         let(:current_user) { owner } | ||||
| 
 | ||||
|         it { is_expected.to be_allowed(:create_runner) } | ||||
| 
 | ||||
|         it_behaves_like 'disallowed when group runner registration disabled' | ||||
|       end | ||||
| 
 | ||||
|       context 'with maintainer' do | ||||
|         let(:current_user) { maintainer } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_runner) } | ||||
|       end | ||||
| 
 | ||||
|       context 'with reporter' do | ||||
|         let(:current_user) { reporter } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_runner) } | ||||
|       end | ||||
| 
 | ||||
|       context 'with guest' do | ||||
|         let(:current_user) { guest } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_runner) } | ||||
|       end | ||||
| 
 | ||||
|       context 'with developer' do | ||||
|         let(:current_user) { developer } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_runner) } | ||||
|       end | ||||
| 
 | ||||
|       context 'with anonymous' do | ||||
|         let(:current_user) { nil } | ||||
| 
 | ||||
|       context 'when admin mode is disabled' do | ||||
|         it { is_expected.to be_disallowed(:create_runner) } | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'with create_runner_workflow_for_namespace flag disabled' do | ||||
|       before do | ||||
|         stub_feature_flags(create_runner_workflow_for_namespace: [other_group]) | ||||
|       end | ||||
|     context 'with owner' do | ||||
|       let(:current_user) { owner } | ||||
| 
 | ||||
|       let_it_be(:other_group) { create(:group) } | ||||
|       it { is_expected.to be_allowed(:create_runner) } | ||||
| 
 | ||||
|       context 'admin' do | ||||
|         let(:current_user) { admin } | ||||
|       it_behaves_like 'disallowed when group runner registration disabled' | ||||
|     end | ||||
| 
 | ||||
|         context 'when admin mode is enabled', :enable_admin_mode do | ||||
|           it { is_expected.to be_disallowed(:create_runner) } | ||||
|     context 'with maintainer' do | ||||
|       let(:current_user) { maintainer } | ||||
| 
 | ||||
|           context 'with specific group runner registration disabled' do | ||||
|             before do | ||||
|               group.runner_registration_enabled = false | ||||
|             end | ||||
|       it { is_expected.to be_disallowed(:create_runner) } | ||||
|     end | ||||
| 
 | ||||
|             it { is_expected.to be_disallowed(:create_runner) } | ||||
|           end | ||||
|     context 'with reporter' do | ||||
|       let(:current_user) { reporter } | ||||
| 
 | ||||
|           it_behaves_like 'disallowed when group runner registration disabled' | ||||
|         end | ||||
|       it { is_expected.to be_disallowed(:create_runner) } | ||||
|     end | ||||
| 
 | ||||
|         context 'when admin mode is disabled' do | ||||
|           it { is_expected.to be_disallowed(:create_runner) } | ||||
|         end | ||||
|       end | ||||
|     context 'with guest' do | ||||
|       let(:current_user) { guest } | ||||
| 
 | ||||
|       context 'with owner' do | ||||
|         let(:current_user) { owner } | ||||
|       it { is_expected.to be_disallowed(:create_runner) } | ||||
|     end | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_runner) } | ||||
|     context 'with developer' do | ||||
|       let(:current_user) { developer } | ||||
| 
 | ||||
|         it_behaves_like 'disallowed when group runner registration disabled' | ||||
|       end | ||||
|       it { is_expected.to be_disallowed(:create_runner) } | ||||
|     end | ||||
| 
 | ||||
|       context 'with maintainer' do | ||||
|         let(:current_user) { maintainer } | ||||
|     context 'with anonymous' do | ||||
|       let(:current_user) { nil } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_runner) } | ||||
|       end | ||||
| 
 | ||||
|       context 'with reporter' do | ||||
|         let(:current_user) { reporter } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_runner) } | ||||
|       end | ||||
| 
 | ||||
|       context 'with guest' do | ||||
|         let(:current_user) { guest } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_runner) } | ||||
|       end | ||||
| 
 | ||||
|       context 'with developer' do | ||||
|         let(:current_user) { developer } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_runner) } | ||||
|       end | ||||
| 
 | ||||
|       context 'with anonymous' do | ||||
|         let(:current_user) { nil } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_runner) } | ||||
|       end | ||||
|       it { is_expected.to be_disallowed(:create_runner) } | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|  |  | |||
|  | @ -2879,42 +2879,10 @@ RSpec.describe ProjectPolicy, feature_category: :system_access do | |||
|   end | ||||
| 
 | ||||
|   describe 'create_runner' do | ||||
|     context 'create_runner_workflow_for_namespace flag enabled' do | ||||
|       before do | ||||
|         stub_feature_flags(create_runner_workflow_for_namespace: [project.namespace]) | ||||
|       end | ||||
| 
 | ||||
|       context 'admin' do | ||||
|         let(:current_user) { admin } | ||||
| 
 | ||||
|         context 'when admin mode is enabled', :enable_admin_mode do | ||||
|           it { is_expected.to be_allowed(:create_runner) } | ||||
| 
 | ||||
|           context 'with project runner registration disabled' do | ||||
|             before do | ||||
|               stub_application_setting(valid_runner_registrars: ['group']) | ||||
|             end | ||||
| 
 | ||||
|             it { is_expected.to be_allowed(:create_runner) } | ||||
|           end | ||||
| 
 | ||||
|           context 'with specific project runner registration disabled' do | ||||
|             before do | ||||
|               project.update!(runner_registration_enabled: false) | ||||
|             end | ||||
| 
 | ||||
|             it { is_expected.to be_allowed(:create_runner) } | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         context 'when admin mode is disabled' do | ||||
|           it { is_expected.to be_disallowed(:create_runner) } | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'with owner' do | ||||
|         let(:current_user) { owner } | ||||
|     context 'admin' do | ||||
|       let(:current_user) { admin } | ||||
| 
 | ||||
|       context 'when admin mode is enabled', :enable_admin_mode do | ||||
|         it { is_expected.to be_allowed(:create_runner) } | ||||
| 
 | ||||
|         context 'with project runner registration disabled' do | ||||
|  | @ -2922,7 +2890,7 @@ RSpec.describe ProjectPolicy, feature_category: :system_access do | |||
|             stub_application_setting(valid_runner_registrars: ['group']) | ||||
|           end | ||||
| 
 | ||||
|           it { is_expected.to be_disallowed(:create_runner) } | ||||
|           it { is_expected.to be_allowed(:create_runner) } | ||||
|         end | ||||
| 
 | ||||
|         context 'with specific project runner registration disabled' do | ||||
|  | @ -2930,125 +2898,65 @@ RSpec.describe ProjectPolicy, feature_category: :system_access do | |||
|             project.update!(runner_registration_enabled: false) | ||||
|           end | ||||
| 
 | ||||
|           it { is_expected.to be_disallowed(:create_runner) } | ||||
|           it { is_expected.to be_allowed(:create_runner) } | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'with maintainer' do | ||||
|         let(:current_user) { maintainer } | ||||
| 
 | ||||
|         it { is_expected.to be_allowed(:create_runner) } | ||||
|       context 'when admin mode is disabled' do | ||||
|         it { is_expected.to be_disallowed(:create_runner) } | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|       context 'with reporter' do | ||||
|         let(:current_user) { reporter } | ||||
|     context 'with owner' do | ||||
|       let(:current_user) { owner } | ||||
| 
 | ||||
|       it { is_expected.to be_allowed(:create_runner) } | ||||
| 
 | ||||
|       context 'with project runner registration disabled' do | ||||
|         before do | ||||
|           stub_application_setting(valid_runner_registrars: ['group']) | ||||
|         end | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_runner) } | ||||
|       end | ||||
| 
 | ||||
|       context 'with guest' do | ||||
|         let(:current_user) { guest } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_runner) } | ||||
|       end | ||||
| 
 | ||||
|       context 'with developer' do | ||||
|         let(:current_user) { developer } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_runner) } | ||||
|       end | ||||
| 
 | ||||
|       context 'with anonymous' do | ||||
|         let(:current_user) { nil } | ||||
|       context 'with specific project runner registration disabled' do | ||||
|         before do | ||||
|           project.update!(runner_registration_enabled: false) | ||||
|         end | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_runner) } | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'create_runner_workflow_for_namespace flag disabled' do | ||||
|       before do | ||||
|         stub_feature_flags(create_runner_workflow_for_namespace: [group]) | ||||
|       end | ||||
|     context 'with maintainer' do | ||||
|       let(:current_user) { maintainer } | ||||
| 
 | ||||
|       context 'admin' do | ||||
|         let(:current_user) { admin } | ||||
|       it { is_expected.to be_allowed(:create_runner) } | ||||
|     end | ||||
| 
 | ||||
|         context 'when admin mode is enabled', :enable_admin_mode do | ||||
|           it { is_expected.to be_disallowed(:create_runner) } | ||||
|     context 'with reporter' do | ||||
|       let(:current_user) { reporter } | ||||
| 
 | ||||
|           context 'with project runner registration disabled' do | ||||
|             before do | ||||
|               stub_application_setting(valid_runner_registrars: ['group']) | ||||
|             end | ||||
|       it { is_expected.to be_disallowed(:create_runner) } | ||||
|     end | ||||
| 
 | ||||
|             it { is_expected.to be_disallowed(:create_runner) } | ||||
|           end | ||||
|     context 'with guest' do | ||||
|       let(:current_user) { guest } | ||||
| 
 | ||||
|           context 'with specific project runner registration disabled' do | ||||
|             before do | ||||
|               project.update!(runner_registration_enabled: false) | ||||
|             end | ||||
|       it { is_expected.to be_disallowed(:create_runner) } | ||||
|     end | ||||
| 
 | ||||
|             it { is_expected.to be_disallowed(:create_runner) } | ||||
|           end | ||||
|         end | ||||
|     context 'with developer' do | ||||
|       let(:current_user) { developer } | ||||
| 
 | ||||
|         context 'when admin mode is disabled' do | ||||
|           it { is_expected.to be_disallowed(:create_runner) } | ||||
|         end | ||||
|       end | ||||
|       it { is_expected.to be_disallowed(:create_runner) } | ||||
|     end | ||||
| 
 | ||||
|       context 'with owner' do | ||||
|         let(:current_user) { owner } | ||||
|     context 'with anonymous' do | ||||
|       let(:current_user) { nil } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_runner) } | ||||
| 
 | ||||
|         context 'with project runner registration disabled' do | ||||
|           before do | ||||
|             stub_application_setting(valid_runner_registrars: ['group']) | ||||
|           end | ||||
| 
 | ||||
|           it { is_expected.to be_disallowed(:create_runner) } | ||||
|         end | ||||
| 
 | ||||
|         context 'with specific project runner registration disabled' do | ||||
|           before do | ||||
|             project.update!(runner_registration_enabled: false) | ||||
|           end | ||||
| 
 | ||||
|           it { is_expected.to be_disallowed(:create_runner) } | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'with maintainer' do | ||||
|         let(:current_user) { maintainer } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_runner) } | ||||
|       end | ||||
| 
 | ||||
|       context 'with reporter' do | ||||
|         let(:current_user) { reporter } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_runner) } | ||||
|       end | ||||
| 
 | ||||
|       context 'with guest' do | ||||
|         let(:current_user) { guest } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_runner) } | ||||
|       end | ||||
| 
 | ||||
|       context 'with developer' do | ||||
|         let(:current_user) { developer } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_runner) } | ||||
|       end | ||||
| 
 | ||||
|       context 'with anonymous' do | ||||
|         let(:current_user) { nil } | ||||
| 
 | ||||
|         it { is_expected.to be_disallowed(:create_runner) } | ||||
|       end | ||||
|       it { is_expected.to be_disallowed(:create_runner) } | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|  |  | |||
|  | @ -95,18 +95,6 @@ RSpec.describe 'RunnerCreate', feature_category: :runner_fleet do | |||
|     end | ||||
|   end | ||||
| 
 | ||||
|   shared_context 'when :create_runner_workflow_for_namespace feature flag is disabled' do | ||||
|     before do | ||||
|       stub_feature_flags(create_runner_workflow_for_namespace: [other_group]) | ||||
|     end | ||||
| 
 | ||||
|     it 'returns an error' do | ||||
|       post_graphql_mutation(mutation, current_user: current_user) | ||||
| 
 | ||||
|       expect_graphql_errors_to_include('`create_runner_workflow_for_namespace` feature flag is disabled.') | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   shared_examples 'when runner is created successfully' do | ||||
|     it do | ||||
|       expected_args = { user: current_user, params: anything } | ||||
|  | @ -139,18 +127,6 @@ RSpec.describe 'RunnerCreate', feature_category: :runner_fleet do | |||
|     context 'when user has permissions', :enable_admin_mode do | ||||
|       let(:current_user) { admin } | ||||
| 
 | ||||
|       context 'when :create_runner_workflow_for_admin feature flag is disabled' do | ||||
|         before do | ||||
|           stub_feature_flags(create_runner_workflow_for_admin: false) | ||||
|         end | ||||
| 
 | ||||
|         it 'returns an error' do | ||||
|           post_graphql_mutation(mutation, current_user: current_user) | ||||
| 
 | ||||
|           expect_graphql_errors_to_include('`create_runner_workflow_for_admin` feature flag is disabled.') | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       it_behaves_like 'when runner is created successfully' | ||||
|       it_behaves_like 'when model is invalid returns error' | ||||
|     end | ||||
|  | @ -164,17 +140,12 @@ RSpec.describe 'RunnerCreate', feature_category: :runner_fleet do | |||
|       } | ||||
|     end | ||||
| 
 | ||||
|     before do | ||||
|       stub_feature_flags(create_runner_workflow_for_namespace: [group]) | ||||
|     end | ||||
| 
 | ||||
|     it_behaves_like 'when user does not have permissions' | ||||
| 
 | ||||
|     context 'when user has permissions' do | ||||
|       context 'when user is group owner' do | ||||
|         let(:current_user) { group_owner } | ||||
| 
 | ||||
|         it_behaves_like 'when :create_runner_workflow_for_namespace feature flag is disabled' | ||||
|         it_behaves_like 'when runner is created successfully' | ||||
|         it_behaves_like 'when model is invalid returns error' | ||||
| 
 | ||||
|  | @ -226,7 +197,6 @@ RSpec.describe 'RunnerCreate', feature_category: :runner_fleet do | |||
|       context 'when user is admin in admin mode', :enable_admin_mode do | ||||
|         let(:current_user) { admin } | ||||
| 
 | ||||
|         it_behaves_like 'when :create_runner_workflow_for_namespace feature flag is disabled' | ||||
|         it_behaves_like 'when runner is created successfully' | ||||
|         it_behaves_like 'when model is invalid returns error' | ||||
|       end | ||||
|  | @ -249,7 +219,6 @@ RSpec.describe 'RunnerCreate', feature_category: :runner_fleet do | |||
|       context 'when user is group owner' do | ||||
|         let(:current_user) { group_owner } | ||||
| 
 | ||||
|         it_behaves_like 'when :create_runner_workflow_for_namespace feature flag is disabled' | ||||
|         it_behaves_like 'when runner is created successfully' | ||||
|         it_behaves_like 'when model is invalid returns error' | ||||
| 
 | ||||
|  | @ -304,7 +273,6 @@ RSpec.describe 'RunnerCreate', feature_category: :runner_fleet do | |||
|       context 'when user is admin in admin mode', :enable_admin_mode do | ||||
|         let(:current_user) { admin } | ||||
| 
 | ||||
|         it_behaves_like 'when :create_runner_workflow_for_namespace feature flag is disabled' | ||||
|         it_behaves_like 'when runner is created successfully' | ||||
|         it_behaves_like 'when model is invalid returns error' | ||||
|       end | ||||
|  |  | |||
|  | @ -5,7 +5,7 @@ require 'spec_helper' | |||
| RSpec.describe ErrorTracking::IssueDetailsService, feature_category: :error_tracking do | ||||
|   include_context 'sentry error tracking context' | ||||
| 
 | ||||
|   subject { described_class.new(project, user, params) } | ||||
|   subject(:service) { described_class.new(project, user, params) } | ||||
| 
 | ||||
|   describe '#execute' do | ||||
|     context 'with authorized user' do | ||||
|  | @ -41,26 +41,41 @@ RSpec.describe ErrorTracking::IssueDetailsService, feature_category: :error_trac | |||
|       include_examples 'error tracking service http status handling', :issue_details | ||||
| 
 | ||||
|       context 'with integrated error tracking' do | ||||
|         let_it_be(:error) { create(:error_tracking_error, project: project) } | ||||
| 
 | ||||
|         let(:params) { { issue_id: error.id } } | ||||
|         let(:error_repository) { instance_double(Gitlab::ErrorTracking::ErrorRepository) } | ||||
|         let(:params) { { issue_id: issue_id } } | ||||
| 
 | ||||
|         before do | ||||
|           error_tracking_setting.update!(integrated: true) | ||||
| 
 | ||||
|           allow(service).to receive(:error_repository).and_return(error_repository) | ||||
|         end | ||||
| 
 | ||||
|         it 'returns the error in detailed format' do | ||||
|           expect(result[:status]).to eq(:success) | ||||
|           expect(result[:issue].to_json).to eq(error.to_sentry_detailed_error.to_json) | ||||
|         context 'when error is found' do | ||||
|           let(:error) { build_stubbed(:error_tracking_open_api_error, project_id: project.id) } | ||||
|           let(:issue_id) { error.fingerprint } | ||||
| 
 | ||||
|           before do | ||||
|             allow(error_repository).to receive(:find_error).with(issue_id).and_return(error) | ||||
|           end | ||||
| 
 | ||||
|           it 'returns the error in detailed format' do | ||||
|             expect(result[:status]).to eq(:success) | ||||
|             expect(result[:issue]).to eq(error) | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         context 'when error does not exist' do | ||||
|           let(:params) { { issue_id: non_existing_record_id } } | ||||
|           let(:issue_id) { non_existing_record_id } | ||||
| 
 | ||||
|           before do | ||||
|             allow(error_repository).to receive(:find_error).with(issue_id) | ||||
|               .and_raise(Gitlab::ErrorTracking::ErrorRepository::DatabaseError.new('Error not found')) | ||||
|           end | ||||
| 
 | ||||
|           it 'returns the error in detailed format' do | ||||
|             expect(result).to match( | ||||
|               status: :error, | ||||
|               message: /Couldn't find ErrorTracking::Error/, | ||||
|               message: /Error not found/, | ||||
|               http_status: :bad_request | ||||
|             ) | ||||
|           end | ||||
|  |  | |||
|  | @ -7,7 +7,7 @@ RSpec.describe ErrorTracking::IssueLatestEventService, feature_category: :error_ | |||
| 
 | ||||
|   let(:params) { {} } | ||||
| 
 | ||||
|   subject { described_class.new(project, user, params) } | ||||
|   subject(:service) { described_class.new(project, user, params) } | ||||
| 
 | ||||
|   describe '#execute' do | ||||
|     context 'with authorized user' do | ||||
|  | @ -29,27 +29,42 @@ RSpec.describe ErrorTracking::IssueLatestEventService, feature_category: :error_ | |||
|       include_examples 'error tracking service http status handling', :issue_latest_event | ||||
| 
 | ||||
|       context 'with integrated error tracking' do | ||||
|         let_it_be(:error) { create(:error_tracking_error, project: project) } | ||||
|         let_it_be(:event) { create(:error_tracking_error_event, error: error) } | ||||
| 
 | ||||
|         let(:params) { { issue_id: error.id } } | ||||
|         let(:error_repository) { instance_double(Gitlab::ErrorTracking::ErrorRepository) } | ||||
|         let(:params) { { issue_id: issue_id } } | ||||
| 
 | ||||
|         before do | ||||
|           error_tracking_setting.update!(integrated: true) | ||||
| 
 | ||||
|           allow(service).to receive(:error_repository).and_return(error_repository) | ||||
|         end | ||||
| 
 | ||||
|         it 'returns the latest event in expected format' do | ||||
|           expect(result[:status]).to eq(:success) | ||||
|           expect(result[:latest_event].to_json).to eq(event.to_sentry_error_event.to_json) | ||||
|         context 'when error is found' do | ||||
|           let(:error) { build_stubbed(:error_tracking_open_api_error, project_id: project.id) } | ||||
|           let(:event) { build_stubbed(:error_tracking_open_api_error_event, fingerprint: error.fingerprint) } | ||||
|           let(:issue_id) { error.fingerprint } | ||||
| 
 | ||||
|           before do | ||||
|             allow(error_repository).to receive(:last_event_for).with(issue_id).and_return(event) | ||||
|           end | ||||
| 
 | ||||
|           it 'returns the latest event in expected format' do | ||||
|             expect(result[:status]).to eq(:success) | ||||
|             expect(result[:latest_event]).to eq(event) | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         context 'when error does not exist' do | ||||
|           let(:params) { { issue_id: non_existing_record_id } } | ||||
|           let(:issue_id) { non_existing_record_id } | ||||
| 
 | ||||
|           before do | ||||
|             allow(error_repository).to receive(:last_event_for).with(issue_id) | ||||
|               .and_raise(Gitlab::ErrorTracking::ErrorRepository::DatabaseError.new('Error not found')) | ||||
|           end | ||||
| 
 | ||||
|           it 'returns the error in detailed format' do | ||||
|             expect(result).to match( | ||||
|               status: :error, | ||||
|               message: /Couldn't find ErrorTracking::Error/, | ||||
|               message: /Error not found/, | ||||
|               http_status: :bad_request | ||||
|             ) | ||||
|           end | ||||
|  |  | |||
|  | @ -113,17 +113,45 @@ RSpec.describe ErrorTracking::IssueUpdateService, feature_category: :error_track | |||
|       include_examples 'error tracking service sentry error handling', :update_issue | ||||
| 
 | ||||
|       context 'with integrated error tracking' do | ||||
|         let(:error) { create(:error_tracking_error, project: project) } | ||||
|         let(:arguments) { { issue_id: error.id, status: 'resolved' } } | ||||
|         let(:update_issue_response) { { updated: true, status: :success, closed_issue_iid: nil } } | ||||
|         let(:error_repository) { instance_double(Gitlab::ErrorTracking::ErrorRepository) } | ||||
|         let(:error) { build_stubbed(:error_tracking_open_api_error, project_id: project.id) } | ||||
|         let(:issue_id) { error.fingerprint } | ||||
|         let(:arguments) { { issue_id: issue_id, status: 'resolved' } } | ||||
| 
 | ||||
|         before do | ||||
|           error_tracking_setting.update!(integrated: true) | ||||
| 
 | ||||
|           allow(update_service).to receive(:error_repository).and_return(error_repository) | ||||
|           allow(error_repository).to receive(:update_error) | ||||
|             .with(issue_id, status: 'resolved').and_return(updated) | ||||
|         end | ||||
| 
 | ||||
|         it 'resolves the error and responds with expected format' do | ||||
|           expect(update_service.execute).to eq(update_issue_response) | ||||
|           expect(error.reload.status).to eq('resolved') | ||||
|         context 'when update succeeded' do | ||||
|           let(:updated) { true } | ||||
| 
 | ||||
|           it 'returns success with updated true' do | ||||
|             expect(project.error_tracking_setting).to receive(:expire_issues_cache) | ||||
| 
 | ||||
|             expect(update_service.execute).to eq( | ||||
|               status: :success, | ||||
|               updated: true, | ||||
|               closed_issue_iid: nil | ||||
|             ) | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         context 'when update failed' do | ||||
|           let(:updated) { false } | ||||
| 
 | ||||
|           it 'returns success with updated false' do | ||||
|             expect(project.error_tracking_setting).to receive(:expire_issues_cache) | ||||
| 
 | ||||
|             expect(update_service.execute).to eq( | ||||
|               status: :success, | ||||
|               updated: false, | ||||
|               closed_issue_iid: nil | ||||
|             ) | ||||
|           end | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|  |  | |||
|  | @ -7,10 +7,10 @@ RSpec.describe ErrorTracking::ListIssuesService, feature_category: :error_tracki | |||
| 
 | ||||
|   let(:params) { {} } | ||||
| 
 | ||||
|   subject { described_class.new(project, user, params) } | ||||
|   subject(:service) { described_class.new(project, user, params) } | ||||
| 
 | ||||
|   describe '#execute' do | ||||
|     context 'Sentry backend' do | ||||
|     context 'with Sentry backend' do | ||||
|       let(:params) { { search_term: 'something', sort: 'last_seen', cursor: 'some-cursor' } } | ||||
| 
 | ||||
|       let(:list_sentry_issues_args) do | ||||
|  | @ -42,7 +42,7 @@ RSpec.describe ErrorTracking::ListIssuesService, feature_category: :error_tracki | |||
|           expect(result).to eq(status: :success, pagination: {}, issues: issues) | ||||
|         end | ||||
| 
 | ||||
|         it 'returns bad request for an issue_status not on the whitelist' do | ||||
|         it 'returns bad request with invalid issue_status' do | ||||
|           params[:issue_status] = 'assigned' | ||||
| 
 | ||||
|           expect(error_tracking_setting).not_to receive(:list_sentry_issues) | ||||
|  | @ -65,22 +65,84 @@ RSpec.describe ErrorTracking::ListIssuesService, feature_category: :error_tracki | |||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'GitLab backend' do | ||||
|       let_it_be(:error1) { create(:error_tracking_error, name: 'foo', project: project) } | ||||
|       let_it_be(:error2) { create(:error_tracking_error, name: 'bar', project: project) } | ||||
|     context 'with integrated error tracking' do | ||||
|       let(:error_repository) { instance_double(Gitlab::ErrorTracking::ErrorRepository) } | ||||
|       let(:errors) { [] } | ||||
|       let(:pagination) { Gitlab::ErrorTracking::ErrorRepository::Pagination.new(nil, nil) } | ||||
|       let(:opts) { default_opts } | ||||
| 
 | ||||
|       let(:params) { { limit: '1' } } | ||||
|       let(:default_opts) do | ||||
|         { | ||||
|           filters: { status: described_class::DEFAULT_ISSUE_STATUS }, | ||||
|           query: nil, | ||||
|           sort: described_class::DEFAULT_SORT, | ||||
|           limit: described_class::DEFAULT_LIMIT, | ||||
|           cursor: nil | ||||
|         } | ||||
|       end | ||||
| 
 | ||||
|       let(:params) { {} } | ||||
| 
 | ||||
|       before do | ||||
|         error_tracking_setting.update!(integrated: true) | ||||
| 
 | ||||
|         allow(service).to receive(:error_repository).and_return(error_repository) | ||||
|       end | ||||
| 
 | ||||
|       it 'returns the error in expected format' do | ||||
|         expect(result[:status]).to eq(:success) | ||||
|         expect(result[:issues].size).to eq(1) | ||||
|         expect(result[:issues].first.to_json).to eq(error2.to_sentry_error.to_json) | ||||
|         expect(result[:pagination][:next][:cursor]).to be_present | ||||
|         expect(result[:pagination][:previous]).to be_nil | ||||
|       context 'when errors are found' do | ||||
|         let(:error) { build_stubbed(:error_tracking_open_api_error, project_id: project.id) } | ||||
|         let(:errors) { [error] } | ||||
| 
 | ||||
|         before do | ||||
|           allow(error_repository).to receive(:list_errors) | ||||
|             .with(**opts) | ||||
|             .and_return([errors, pagination]) | ||||
|         end | ||||
| 
 | ||||
|         context 'without params' do | ||||
|           it 'returns the errors without pagination' do | ||||
|             expect(result[:status]).to eq(:success) | ||||
|             expect(result[:issues]).to eq(errors) | ||||
|             expect(result[:pagination]).to eq({}) | ||||
|             expect(error_repository).to have_received(:list_errors).with(**opts) | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         context 'with pagination' do | ||||
|           context 'with next page' do | ||||
|             before do | ||||
|               pagination.next = 'next cursor' | ||||
|             end | ||||
| 
 | ||||
|             it 'has next cursor' do | ||||
|               expect(result[:pagination]).to eq(next: { cursor: 'next cursor' }) | ||||
|             end | ||||
|           end | ||||
| 
 | ||||
|           context 'with prev page' do | ||||
|             before do | ||||
|               pagination.prev = 'prev cursor' | ||||
|             end | ||||
| 
 | ||||
|             it 'has prev cursor' do | ||||
|               expect(result[:pagination]).to eq(previous: { cursor: 'prev cursor' }) | ||||
|             end | ||||
|           end | ||||
| 
 | ||||
|           context 'with next and prev page' do | ||||
|             before do | ||||
|               pagination.next = 'next cursor' | ||||
|               pagination.prev = 'prev cursor' | ||||
|             end | ||||
| 
 | ||||
|             it 'has both cursors' do | ||||
|               expect(result[:pagination]).to eq( | ||||
|                 next: { cursor: 'next cursor' }, | ||||
|                 previous: { cursor: 'prev cursor' } | ||||
|               ) | ||||
|             end | ||||
|           end | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  |  | |||
|  | @ -9123,10 +9123,6 @@ | |||
| - './spec/services/environments/schedule_to_delete_review_apps_service_spec.rb' | ||||
| - './spec/services/environments/stop_service_spec.rb' | ||||
| - './spec/services/error_tracking/base_service_spec.rb' | ||||
| - './spec/services/error_tracking/issue_details_service_spec.rb' | ||||
| - './spec/services/error_tracking/issue_latest_event_service_spec.rb' | ||||
| - './spec/services/error_tracking/issue_update_service_spec.rb' | ||||
| - './spec/services/error_tracking/list_issues_service_spec.rb' | ||||
| - './spec/services/error_tracking/list_projects_service_spec.rb' | ||||
| - './spec/services/event_create_service_spec.rb' | ||||
| - './spec/services/events/destroy_service_spec.rb' | ||||
|  |  | |||
|  | @ -15,66 +15,27 @@ RSpec.describe 'projects/runners/_project_runners.html.haml', feature_category: | |||
|       allow(view).to receive(:reset_registration_token_namespace_project_settings_ci_cd_path).and_return('banana_url') | ||||
|     end | ||||
| 
 | ||||
|     context 'when create_runner_workflow_for_namespace is disabled' do | ||||
|     context 'when user can create project runner' do | ||||
|       before do | ||||
|         stub_feature_flags(create_runner_workflow_for_namespace: false) | ||||
|         allow(view).to receive(:can?).with(user, :create_runner, project).and_return(true) | ||||
|       end | ||||
| 
 | ||||
|       context 'when project runner registration is allowed' do | ||||
|         before do | ||||
|           stub_application_setting(valid_runner_registrars: ['project']) | ||||
|           allow(view).to receive(:can?).with(user, :register_project_runners, project).and_return(true) | ||||
|         end | ||||
|       it 'renders the New project runner button' do | ||||
|         render 'projects/runners/project_runners', project: project | ||||
| 
 | ||||
|         it 'enables the Remove project button for a project' do | ||||
|           render 'projects/runners/project_runners', project: project | ||||
| 
 | ||||
|           expect(rendered).to have_selector '#js-install-runner' | ||||
|           expect(rendered).not_to have_content 'Please contact an admin to register runners.' | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'when project runner registration is not allowed' do | ||||
|         before do | ||||
|           stub_application_setting(valid_runner_registrars: ['group']) | ||||
|         end | ||||
| 
 | ||||
|         it 'does not enable the Remove project button for a project' do | ||||
|           render 'projects/runners/project_runners', project: project | ||||
| 
 | ||||
|           expect(rendered).to have_content 'Please contact an admin to register runners.' | ||||
|           expect(rendered).not_to have_selector '#js-install-runner' | ||||
|         end | ||||
|         expect(rendered).to have_link(s_('Runners|New project runner'), href: new_project_runner_path(project)) | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'when create_runner_workflow_for_namespace is enabled' do | ||||
|     context 'when user cannot create project runner' do | ||||
|       before do | ||||
|         stub_feature_flags(create_runner_workflow_for_namespace: project.namespace) | ||||
|         allow(view).to receive(:can?).with(user, :create_runner, project).and_return(false) | ||||
|       end | ||||
| 
 | ||||
|       context 'when user can create project runner' do | ||||
|         before do | ||||
|           allow(view).to receive(:can?).with(user, :create_runner, project).and_return(true) | ||||
|         end | ||||
|       it 'does not render the New project runner button' do | ||||
|         render 'projects/runners/project_runners', project: project | ||||
| 
 | ||||
|         it 'renders the New project runner button' do | ||||
|           render 'projects/runners/project_runners', project: project | ||||
| 
 | ||||
|           expect(rendered).to have_link(s_('Runners|New project runner'), href: new_project_runner_path(project)) | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'when user cannot create project runner' do | ||||
|         before do | ||||
|           allow(view).to receive(:can?).with(user, :create_runner, project).and_return(false) | ||||
|         end | ||||
| 
 | ||||
|         it 'does not render the New project runner button' do | ||||
|           render 'projects/runners/project_runners', project: project | ||||
| 
 | ||||
|           expect(rendered).not_to have_link(s_('Runners|New project runner')) | ||||
|         end | ||||
|         expect(rendered).not_to have_link(s_('Runners|New project runner')) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue