Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
		
							parent
							
								
									533d1ccc98
								
							
						
					
					
						commit
						61ae9de492
					
				|  | @ -93,6 +93,8 @@ class InvitesController < ApplicationController | ||||||
|     store_location_for(:user, invite_landing_url) if member |     store_location_for(:user, invite_landing_url) if member | ||||||
| 
 | 
 | ||||||
|     if user_sign_up? |     if user_sign_up? | ||||||
|  |       session[:invite_email] = member.invite_email | ||||||
|  | 
 | ||||||
|       redirect_to new_user_registration_path(invite_email: member.invite_email), notice: _("To accept this invitation, create an account or sign in.") |       redirect_to new_user_registration_path(invite_email: member.invite_email), notice: _("To accept this invitation, create an account or sign in.") | ||||||
|     else |     else | ||||||
|       redirect_to new_user_session_path(sign_in_redirect_params), notice: sign_in_notice |       redirect_to new_user_session_path(sign_in_redirect_params), notice: sign_in_notice | ||||||
|  |  | ||||||
|  | @ -155,13 +155,21 @@ class RegistrationsController < Devise::RegistrationsController | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def resource |   def resource | ||||||
|     @resource ||= Users::BuildService.new(current_user, sign_up_params).execute |     @resource ||= Users::RegistrationsBuildService | ||||||
|  |                     .new(current_user, sign_up_params.merge({ skip_confirmation: skip_email_confirmation? })) | ||||||
|  |                     .execute | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def devise_mapping |   def devise_mapping | ||||||
|     @devise_mapping ||= Devise.mappings[:user] |     @devise_mapping ||= Devise.mappings[:user] | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   def skip_email_confirmation? | ||||||
|  |     invite_email = session.delete(:invite_email) | ||||||
|  | 
 | ||||||
|  |     sign_up_params[:email] == invite_email | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   def load_recaptcha |   def load_recaptcha | ||||||
|     Gitlab::Recaptcha.load_configurations! |     Gitlab::Recaptcha.load_configurations! | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  | @ -39,10 +39,12 @@ class DeploymentsFinder | ||||||
|   private |   private | ||||||
| 
 | 
 | ||||||
|   def init_collection |   def init_collection | ||||||
|     if params[:project] |     if params[:project].present? | ||||||
|       params[:project].deployments |       params[:project].deployments | ||||||
|  |     elsif params[:group].present? | ||||||
|  |       ::Deployment.for_projects(params[:group].all_projects) | ||||||
|     else |     else | ||||||
|       Deployment.none |       ::Deployment.none | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  | @ -113,5 +115,3 @@ class DeploymentsFinder | ||||||
|   end |   end | ||||||
|   # rubocop: enable CodeReuse/ActiveRecord |   # rubocop: enable CodeReuse/ActiveRecord | ||||||
| end | end | ||||||
| 
 |  | ||||||
| DeploymentsFinder.prepend_if_ee('EE::DeploymentsFinder') |  | ||||||
|  |  | ||||||
|  | @ -14,9 +14,11 @@ module Users | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def execute(skip_authorization: false) |     def execute(skip_authorization: false) | ||||||
|  |       @skip_authorization = skip_authorization | ||||||
|  | 
 | ||||||
|       raise Gitlab::Access::AccessDeniedError unless skip_authorization || can_create_user? |       raise Gitlab::Access::AccessDeniedError unless skip_authorization || can_create_user? | ||||||
| 
 | 
 | ||||||
|       user_params = build_user_params(skip_authorization: skip_authorization) |       user_params = build_user_params | ||||||
|       user = User.new(user_params) |       user = User.new(user_params) | ||||||
| 
 | 
 | ||||||
|       if current_user&.admin? |       if current_user&.admin? | ||||||
|  | @ -37,6 +39,8 @@ module Users | ||||||
| 
 | 
 | ||||||
|     private |     private | ||||||
| 
 | 
 | ||||||
|  |     attr_reader :skip_authorization | ||||||
|  | 
 | ||||||
|     def identity_attributes |     def identity_attributes | ||||||
|       [:extern_uid, :provider] |       [:extern_uid, :provider] | ||||||
|     end |     end | ||||||
|  | @ -102,7 +106,7 @@ module Users | ||||||
|       ] |       ] | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def build_user_params(skip_authorization:) |     def build_user_params | ||||||
|       if current_user&.admin? |       if current_user&.admin? | ||||||
|         user_params = params.slice(*admin_create_params) |         user_params = params.slice(*admin_create_params) | ||||||
| 
 | 
 | ||||||
|  | @ -111,10 +115,10 @@ module Users | ||||||
|         end |         end | ||||||
|       else |       else | ||||||
|         allowed_signup_params = signup_params |         allowed_signup_params = signup_params | ||||||
|         allowed_signup_params << :skip_confirmation if skip_authorization |         allowed_signup_params << :skip_confirmation if allow_caller_to_request_skip_confirmation? | ||||||
| 
 | 
 | ||||||
|         user_params = params.slice(*allowed_signup_params) |         user_params = params.slice(*allowed_signup_params) | ||||||
|         if user_params[:skip_confirmation].nil? |         if assign_skip_confirmation_from_settings?(user_params) | ||||||
|           user_params[:skip_confirmation] = skip_user_confirmation_email_from_setting |           user_params[:skip_confirmation] = skip_user_confirmation_email_from_setting | ||||||
|         end |         end | ||||||
| 
 | 
 | ||||||
|  | @ -136,6 +140,14 @@ module Users | ||||||
|       user_params |       user_params | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|  |     def allow_caller_to_request_skip_confirmation? | ||||||
|  |       skip_authorization | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def assign_skip_confirmation_from_settings?(user_params) | ||||||
|  |       user_params[:skip_confirmation].nil? | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|     def skip_user_confirmation_email_from_setting |     def skip_user_confirmation_email_from_setting | ||||||
|       !Gitlab::CurrentSettings.send_user_confirmation_email |       !Gitlab::CurrentSettings.send_user_confirmation_email | ||||||
|     end |     end | ||||||
|  |  | ||||||
|  | @ -0,0 +1,19 @@ | ||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | module Users | ||||||
|  |   class RegistrationsBuildService < BuildService | ||||||
|  |     extend ::Gitlab::Utils::Override | ||||||
|  | 
 | ||||||
|  |     private | ||||||
|  | 
 | ||||||
|  |     override :allow_caller_to_request_skip_confirmation? | ||||||
|  |     def allow_caller_to_request_skip_confirmation? | ||||||
|  |       true | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     override :assign_skip_confirmation_from_settings? | ||||||
|  |     def assign_skip_confirmation_from_settings?(user_params) | ||||||
|  |       user_params[:skip_confirmation].blank? | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -10,7 +10,7 @@ | ||||||
|   .controls.d-flex.align-items-center |   .controls.d-flex.align-items-center | ||||||
|     %a.btn.gl-button.btn-default.gl-mr-3{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "template_preview", track_property: template.name, track_event: "click_button", track_value: "" } } |     %a.btn.gl-button.btn-default.gl-mr-3{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "template_preview", track_property: template.name, track_event: "click_button", track_value: "" } } | ||||||
|       = _("Preview") |       = _("Preview") | ||||||
|     %label.btn.gl-button.btn-success.template-button.choose-template.gl-mb-0{ for: template.name } |     %label.btn.gl-button.btn-confirm.template-button.choose-template.gl-mb-0{ for: template.name } | ||||||
|       %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "template_use", track_property: template.name, track_event: "click_button", track_value: "" } } |       %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "template_use", track_property: template.name, track_event: "click_button", track_value: "" } } | ||||||
|       %span{ data: { qa_selector: 'use_template_button' } } |       %span{ data: { qa_selector: 'use_template_button' } } | ||||||
|         = _("Use template") |         = _("Use template") | ||||||
|  |  | ||||||
|  | @ -0,0 +1,5 @@ | ||||||
|  | --- | ||||||
|  | title: Do not require invited users to confirm their email address | ||||||
|  | merge_request: 59790 | ||||||
|  | author: | ||||||
|  | type: other | ||||||
|  | @ -0,0 +1,5 @@ | ||||||
|  | --- | ||||||
|  | title: Add index to support execution time order for batched migration jobs | ||||||
|  | merge_request: 60133 | ||||||
|  | author: | ||||||
|  | type: other | ||||||
|  | @ -0,0 +1,5 @@ | ||||||
|  | --- | ||||||
|  | title: Move to btn-confirm from btn-success in create from template page | ||||||
|  | merge_request: 58303 | ||||||
|  | author: Yogi (@yo) | ||||||
|  | type: changed | ||||||
|  | @ -0,0 +1,5 @@ | ||||||
|  | --- | ||||||
|  | title: Remove Legacy Group-Level DORA metrics API | ||||||
|  | merge_request: 59858 | ||||||
|  | author: | ||||||
|  | type: removed | ||||||
|  | @ -0,0 +1,8 @@ | ||||||
|  | --- | ||||||
|  | name: optimize_batched_migrations | ||||||
|  | introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/60133 | ||||||
|  | rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/328817 | ||||||
|  | milestone: '13.12' | ||||||
|  | type: ops | ||||||
|  | group: group::database | ||||||
|  | default_enabled: false | ||||||
|  | @ -0,0 +1,17 @@ | ||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class AddExecutionOrderIndexToBatchedBackgroundMigrationJobs < ActiveRecord::Migration[6.0] | ||||||
|  |   include Gitlab::Database::MigrationHelpers | ||||||
|  | 
 | ||||||
|  |   disable_ddl_transaction! | ||||||
|  | 
 | ||||||
|  |   INDEX_NAME = 'index_migration_jobs_on_migration_id_and_finished_at' | ||||||
|  | 
 | ||||||
|  |   def up | ||||||
|  |     add_concurrent_index :batched_background_migration_jobs, %i(batched_background_migration_id finished_at), name: INDEX_NAME | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def down | ||||||
|  |     remove_concurrent_index_by_name :batched_background_migration_jobs, INDEX_NAME | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -0,0 +1 @@ | ||||||
|  | aa0ae491a7f94d99ea0c42250434245a4f23b0084657b709b0aaad0317dfd6b1 | ||||||
|  | @ -23256,6 +23256,8 @@ CREATE INDEX index_metrics_dashboard_annotations_on_timespan_end ON metrics_dash | ||||||
| 
 | 
 | ||||||
| CREATE INDEX index_metrics_users_starred_dashboards_on_project_id ON metrics_users_starred_dashboards USING btree (project_id); | CREATE INDEX index_metrics_users_starred_dashboards_on_project_id ON metrics_users_starred_dashboards USING btree (project_id); | ||||||
| 
 | 
 | ||||||
|  | CREATE INDEX index_migration_jobs_on_migration_id_and_finished_at ON batched_background_migration_jobs USING btree (batched_background_migration_id, finished_at); | ||||||
|  | 
 | ||||||
| CREATE INDEX index_milestone_releases_on_release_id ON milestone_releases USING btree (release_id); | CREATE INDEX index_milestone_releases_on_release_id ON milestone_releases USING btree (release_id); | ||||||
| 
 | 
 | ||||||
| CREATE INDEX index_milestones_on_description_trigram ON milestones USING gin (description gin_trgm_ops); | CREATE INDEX index_milestones_on_description_trigram ON milestones USING gin (description gin_trgm_ops); | ||||||
|  |  | ||||||
|  | @ -1445,3 +1445,16 @@ Here are common errors and potential causes: | ||||||
|   - **GRPC::Unavailable (14:all SubCons are in TransientFailure...)** |   - **GRPC::Unavailable (14:all SubCons are in TransientFailure...)** | ||||||
|     - Praefect cannot reach one or more of its child Gitaly nodes. Try running |     - Praefect cannot reach one or more of its child Gitaly nodes. Try running | ||||||
|       the Praefect connection checker to diagnose. |       the Praefect connection checker to diagnose. | ||||||
|  | 
 | ||||||
|  | ### Determine primary Gitaly node | ||||||
|  | 
 | ||||||
|  | To determine the current primary Gitaly node for a specific Praefect node: | ||||||
|  | 
 | ||||||
|  | - Use the `Shard Primary Election` [Grafana chart](#grafana) on the [`Gitlab Omnibus - Praefect` dashboard](https://gitlab.com/gitlab-org/grafana-dashboards/-/blob/master/omnibus/praefect.json). | ||||||
|  |   This is recommended. | ||||||
|  | - If you do not have Grafana set up, use the following command on each host of each | ||||||
|  |   Praefect node:  | ||||||
|  | 
 | ||||||
|  |   ```shell | ||||||
|  |   curl localhost:9652/metrics | grep gitaly_praefect_primaries` | ||||||
|  |   ``` | ||||||
|  |  | ||||||
|  | @ -1,87 +1,8 @@ | ||||||
| --- | --- | ||||||
| stage: Release | redirect_to: 'dora/metrics.md' | ||||||
| group: Release |  | ||||||
| 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 |  | ||||||
| type: reference, api |  | ||||||
| --- | --- | ||||||
| 
 | 
 | ||||||
| # DORA4 Analytics Group API **(ULTIMATE SELF)** | This document was moved to [another location](dora/metrics.md). | ||||||
| 
 | 
 | ||||||
| > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/291747) in GitLab 13.9. | <!-- This redirect file can be deleted after <2021-07-25>. --> | ||||||
| > - [Deployed behind a feature flag](../user/feature_flags.md), disabled by default. | <!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page --> | ||||||
| > - Disabled on GitLab.com. |  | ||||||
| > - Not recommended for production use. |  | ||||||
| > - To use in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-dora4-analytics-group-api). |  | ||||||
| 
 |  | ||||||
| WARNING: |  | ||||||
| These endpoints are deprecated and will be removed in GitLab 14.0. Use the [DORA metrics API](dora/metrics.md) instead. |  | ||||||
| 
 |  | ||||||
| WARNING: |  | ||||||
| This feature might not be available to you. Check the **version history** note above for details. |  | ||||||
| 
 |  | ||||||
| All methods require reporter authorization. |  | ||||||
| 
 |  | ||||||
| ## List group deployment frequencies |  | ||||||
| 
 |  | ||||||
| Get a list of all group deployment frequencies: |  | ||||||
| 
 |  | ||||||
| ```plaintext |  | ||||||
| GET /groups/:id/analytics/deployment_frequency?environment=:environment&from=:from&to=:to&interval=:interval |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| Attributes: |  | ||||||
| 
 |  | ||||||
| | Attribute    | Type   | Required | Description           | |  | ||||||
| |--------------|--------|----------|-----------------------| |  | ||||||
| | `id`         | string | yes      | The ID of the group. | |  | ||||||
| 
 |  | ||||||
| Parameters: |  | ||||||
| 
 |  | ||||||
| | Parameter    | Type   | Required | Description           | |  | ||||||
| |--------------|--------|----------|-----------------------| |  | ||||||
| | `environment`| string | yes      | The name of the environment to filter by. | |  | ||||||
| | `from`       | string | yes      | Datetime range to start from. Inclusive, ISO 8601 format (`YYYY-MM-DDTHH:MM:SSZ`). | |  | ||||||
| | `to`         | string | no       | Datetime range to end at. Exclusive, ISO 8601 format (`YYYY-MM-DDTHH:MM:SSZ`). | |  | ||||||
| | `interval`   | string | no       | The bucketing interval (`all`, `monthly`, `daily`). | |  | ||||||
| 
 |  | ||||||
| Example request: |  | ||||||
| 
 |  | ||||||
| ```shell |  | ||||||
| curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/:id/analytics/deployment_frequency?environment=:environment&from=:from&to=:to&interval=:interval" |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| Example response: |  | ||||||
| 
 |  | ||||||
| ```json |  | ||||||
| [ |  | ||||||
|   { |  | ||||||
|     "from": "2017-01-01", |  | ||||||
|     "to": "2017-01-02", |  | ||||||
|     "value": 106 |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     "from": "2017-01-02", |  | ||||||
|     "to": "2017-01-03", |  | ||||||
|     "value": 55 |  | ||||||
|   } |  | ||||||
| ] |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| ## Enable or disable DORA4 Analytics Group API **(ULTIMATE SELF)** |  | ||||||
| 
 |  | ||||||
| DORA4 Analytics Group API is under development and not ready for production use. It is |  | ||||||
| deployed behind a feature flag that is **disabled by default**. |  | ||||||
| [GitLab administrators with access to the GitLab Rails console](../administration/feature_flags.md) |  | ||||||
| can enable it. |  | ||||||
| 
 |  | ||||||
| To enable it: |  | ||||||
| 
 |  | ||||||
| ```ruby |  | ||||||
| Feature.enable(:dora4_group_deployment_frequency_api) |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| To disable it: |  | ||||||
| 
 |  | ||||||
| ```ruby |  | ||||||
| Feature.disable(:dora4_group_deployment_frequency_api) |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
|  | @ -676,6 +676,7 @@ fetch = +refs/environments/*:refs/remotes/origin/environments/* | ||||||
| 
 | 
 | ||||||
| > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/2112) in [GitLab Premium](https://about.gitlab.com/pricing/) 9.4. | > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/2112) in [GitLab Premium](https://about.gitlab.com/pricing/) 9.4. | ||||||
| > - [Environment scoping for CI/CD variables was moved to all tiers](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/30779) in GitLab 12.2. | > - [Environment scoping for CI/CD variables was moved to all tiers](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/30779) in GitLab 12.2. | ||||||
|  | > - [Environment scoping for Group CI/CD variables](https://gitlab.com/gitlab-org/gitlab/-/issues/2874) added to GitLab Premium in 13.11 | ||||||
| 
 | 
 | ||||||
| You can limit the environment scope of a CI/CD variable by | You can limit the environment scope of a CI/CD variable by | ||||||
| defining which environments it can be available for. | defining which environments it can be available for. | ||||||
|  |  | ||||||
|  | @ -43,7 +43,7 @@ predefined variable: | ||||||
| test_variable: | test_variable: | ||||||
|   stage: test |   stage: test | ||||||
|   script: |   script: | ||||||
|     - echo $CI_JOB_STAGE |     - echo "$CI_JOB_STAGE" | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
| The script outputs the `stage` for the `test_variable`, which is `test`: | The script outputs the `stage` for the `test_variable`, which is `test`: | ||||||
|  | @ -88,7 +88,7 @@ job1: | ||||||
|   variables: |   variables: | ||||||
|     TEST_VAR_JOB: "Only job1 can use this variable's value" |     TEST_VAR_JOB: "Only job1 can use this variable's value" | ||||||
|   script: |   script: | ||||||
|     - echo $TEST_VAR and $TEST_VAR_JOB |     - echo "$TEST_VAR" and "$TEST_VAR_JOB" | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
| Variables saved in the `.gitlab-ci.yml` file should store only non-sensitive project | Variables saved in the `.gitlab-ci.yml` file should store only non-sensitive project | ||||||
|  | @ -114,9 +114,9 @@ name inside another variable: | ||||||
| ```yaml | ```yaml | ||||||
| variables: | variables: | ||||||
|   FLAGS: '-al' |   FLAGS: '-al' | ||||||
|   LS_CMD: 'ls $FLAGS $$TMP_DIR' |   LS_CMD: 'ls "$FLAGS" $$TMP_DIR' | ||||||
| script: | script: | ||||||
|   - 'eval $LS_CMD'  # Executes 'ls -al $TMP_DIR' |   - 'eval "$LS_CMD"'  # Executes 'ls -al $TMP_DIR' | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
| Use the [`value` and `description`](../yaml/README.md#prefill-variables-in-manual-pipelines) | Use the [`value` and `description`](../yaml/README.md#prefill-variables-in-manual-pipelines) | ||||||
|  | @ -151,10 +151,10 @@ After you create a variable, you can use it in the `.gitlab-ci.yml` file: | ||||||
| test_variable: | test_variable: | ||||||
|   stage: test |   stage: test | ||||||
|   script: |   script: | ||||||
|     - echo $CI_JOB_STAGE  # calls a predefined variable |     - echo "$CI_JOB_STAGE"  # calls a predefined variable | ||||||
|     - echo $TEST          # calls a custom variable of type `env_var` |     - echo "$TEST"          # calls a custom variable of type `env_var` | ||||||
|     - echo $GREETING      # calls a custom variable of type `file` that contains the path to the temp file |     - echo "$GREETING"      # calls a custom variable of type `file` that contains the path to the temp file | ||||||
|     - cat $GREETING       # the temp file itself contains the variable value |     - cat "$GREETING"       # the temp file itself contains the variable value | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
| The output is: | The output is: | ||||||
|  | @ -181,7 +181,7 @@ To add a group variable: | ||||||
|    - **Key**: Must be one line, with no spaces, using only letters, numbers, or `_`. |    - **Key**: Must be one line, with no spaces, using only letters, numbers, or `_`. | ||||||
|    - **Value**: No limitations. |    - **Value**: No limitations. | ||||||
|    - **Type**: [`File` or `Variable`](#cicd-variable-types). |    - **Type**: [`File` or `Variable`](#cicd-variable-types). | ||||||
|    - **Environment scope** (optional): `All`, or specific [environments](#limit-the-environment-scope-of-a-cicd-variable). |    - **Environment scope** (optional): `All`, or specific [environments](#limit-the-environment-scope-of-a-cicd-variable). **PREMIUM** | ||||||
|    - **Protect variable** (Optional): If selected, the variable is only available |    - **Protect variable** (Optional): If selected, the variable is only available | ||||||
|      in pipelines that run on protected branches or tags. |      in pipelines that run on protected branches or tags. | ||||||
|    - **Mask variable** (Optional): If selected, the variable's **Value** is masked |    - **Mask variable** (Optional): If selected, the variable's **Value** is masked | ||||||
|  | @ -366,7 +366,7 @@ CI/CD variable with (`$`): | ||||||
| ```yaml | ```yaml | ||||||
| job_name: | job_name: | ||||||
|   script: |   script: | ||||||
|     - echo $CI_JOB_ID |     - echo "$CI_JOB_ID" | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
| ### Use variables with PowerShell | ### Use variables with PowerShell | ||||||
|  | @ -506,7 +506,7 @@ build: | ||||||
| deploy: | deploy: | ||||||
|   stage: deploy |   stage: deploy | ||||||
|   script: |   script: | ||||||
|     - echo $BUILD_VERSION  # Output is: 'hello' |     - echo "$BUILD_VERSION"  # Output is: 'hello' | ||||||
|   dependencies: |   dependencies: | ||||||
|     - build |     - build | ||||||
| ``` | ``` | ||||||
|  | @ -525,7 +525,7 @@ build: | ||||||
| deploy: | deploy: | ||||||
|   stage: deploy |   stage: deploy | ||||||
|   script: |   script: | ||||||
|     - echo $BUILD_VERSION  # Output is: 'hello' |     - echo "$BUILD_VERSION"  # Output is: 'hello' | ||||||
|   needs: |   needs: | ||||||
|     - job: build |     - job: build | ||||||
|       artifacts: true |       artifacts: true | ||||||
|  |  | ||||||
|  | @ -77,6 +77,7 @@ The following languages and dependency managers are supported: | ||||||
| 1. Support for [sbt](https://www.scala-sbt.org/) 1.3 and above was added in GitLab 13.9. | 1. Support for [sbt](https://www.scala-sbt.org/) 1.3 and above was added in GitLab 13.9. | ||||||
| 
 | 
 | ||||||
| Plans are underway for supporting the following languages, dependency managers, and dependency files. For details, see the issue link for each. | Plans are underway for supporting the following languages, dependency managers, and dependency files. For details, see the issue link for each. | ||||||
|  | For workarounds, see the [Troubleshooting section](#troubleshooting) | ||||||
| 
 | 
 | ||||||
| | Package Managers    | Languages | Supported files | Scan tools | Issue | | | Package Managers    | Languages | Supported files | Scan tools | Issue | | ||||||
| | ------------------- | --------- | --------------- | ---------- | ----- | | | ------------------- | --------- | --------------- | ---------- | ----- | | ||||||
|  | @ -568,6 +569,53 @@ As a workaround, remove the [`retire.js`](analyzers.md#selecting-specific-analyz | ||||||
| 
 | 
 | ||||||
| ## Troubleshooting | ## Troubleshooting | ||||||
| 
 | 
 | ||||||
|  | ### Working around missing support for certain languages or package managers | ||||||
|  | 
 | ||||||
|  | As noted in the ["Supported languages" section](#supported-languages-and-package-managers) | ||||||
|  | some dependency definition files are not yet supported. | ||||||
|  | However, Dependency Scanning can be achieved if | ||||||
|  | the language, a package manager, or a third-party tool | ||||||
|  | can convert the definition file | ||||||
|  | into a supported format. | ||||||
|  | 
 | ||||||
|  | Generally, the approach is the following: | ||||||
|  | 
 | ||||||
|  | 1. Define a dedicated converter job in your `.gitlab-ci.yml` file. | ||||||
|  |    Use a suitable Docker image, script, or both to facilitate the conversion. | ||||||
|  | 1. Let that job upload the converted, supported file as an artifact. | ||||||
|  | 1. Add [`dependencies: [<your-converter-job>]`](../../../ci/yaml/README.md#dependencies) | ||||||
|  |    to your `dependency_scanning` job to make use of the converted definitions files. | ||||||
|  | 
 | ||||||
|  | For example, the currently unsupported `poetry.lock` file can be | ||||||
|  | [converted](https://python-poetry.org/docs/cli/#export) | ||||||
|  | to the supported `requirements.txt` as follows. | ||||||
|  | 
 | ||||||
|  | ```yaml | ||||||
|  | include: | ||||||
|  |   - template: Dependency-Scanning.gitlab-ci.yml | ||||||
|  | 
 | ||||||
|  | stages: | ||||||
|  |   - .pre | ||||||
|  |   - test | ||||||
|  | 
 | ||||||
|  | variables: | ||||||
|  |   PIP_REQUIREMENTS_FILE: "requirements-converted.txt" | ||||||
|  | 
 | ||||||
|  | convert-poetry: | ||||||
|  |   stage: .pre | ||||||
|  |   image: python:3-slim | ||||||
|  |   script: | ||||||
|  |     - pip install poetry  # Or via another method: https://python-poetry.org/docs/#installation | ||||||
|  |     - poetry export --output "$PIP_REQUIREMENTS_FILE" | ||||||
|  |   artifacts: | ||||||
|  |     paths: | ||||||
|  |       - "$PIP_REQUIREMENTS_FILE" | ||||||
|  | 
 | ||||||
|  | dependency_scanning: | ||||||
|  |   stage: test | ||||||
|  |   dependencies: ["convert-poetry"] | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
| ### `Error response from daemon: error processing tar file: docker-tar: relocation error` | ### `Error response from daemon: error processing tar file: docker-tar: relocation error` | ||||||
| 
 | 
 | ||||||
| This error occurs when the Docker version that runs the dependency scanning job is `19.03.0`. | This error occurs when the Docker version that runs the dependency scanning job is `19.03.0`. | ||||||
|  |  | ||||||
|  | @ -645,9 +645,6 @@ The group's new subgroups have push rules set for them based on either: | ||||||
|   and issues) of group members. **(PREMIUM)** |   and issues) of group members. **(PREMIUM)** | ||||||
| - [Issue analytics](issues_analytics/index.md): View a bar chart of your group's number of issues per month. **(PREMIUM)** | - [Issue analytics](issues_analytics/index.md): View a bar chart of your group's number of issues per month. **(PREMIUM)** | ||||||
| - Use GitLab as a [dependency proxy](../packages/dependency_proxy/index.md) for upstream Docker images. | - Use GitLab as a [dependency proxy](../packages/dependency_proxy/index.md) for upstream Docker images. | ||||||
| - [DORA4 Project Analytics API](../../api/dora4_group_analytics.md): View deployment frequency analytics. |  | ||||||
|   [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/291747) in GitLab Ultimate 13.9 as a |  | ||||||
|   [Beta feature](https://about.gitlab.com/handbook/product/gitlab-the-product/#beta). **(ULTIMATE SELF)** |  | ||||||
| - [Epics](epics/index.md): Track groups of issues that share a theme. **(ULTIMATE)** | - [Epics](epics/index.md): Track groups of issues that share a theme. **(ULTIMATE)** | ||||||
| - [Security Dashboard](../application_security/security_dashboard/index.md): View the vulnerabilities of all | - [Security Dashboard](../application_security/security_dashboard/index.md): View the vulnerabilities of all | ||||||
|   the projects in a group and its subgroups. **(ULTIMATE)** |   the projects in a group and its subgroups. **(ULTIMATE)** | ||||||
|  |  | ||||||
|  | @ -0,0 +1,68 @@ | ||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | module Gitlab | ||||||
|  |   module Database | ||||||
|  |     module BackgroundMigration | ||||||
|  |       # This is an optimizer for throughput of batched migration jobs | ||||||
|  |       # | ||||||
|  |       # The underyling mechanic is based on the concept of time efficiency: | ||||||
|  |       #     time efficiency = job duration / interval | ||||||
|  |       # Ideally, this is close but lower than 1 - so we're using time efficiently. | ||||||
|  |       # | ||||||
|  |       # We aim to land in the 90%-98% range, which gives the database a little breathing room | ||||||
|  |       # in between. | ||||||
|  |       # | ||||||
|  |       # The optimizer is based on calculating the exponential moving average of time efficiencies | ||||||
|  |       # for the last N jobs. If we're outside the range, we add 10% to or decrease by 20% of the batch size. | ||||||
|  |       class BatchOptimizer | ||||||
|  |         # Target time efficiency for a job | ||||||
|  |         # Time efficiency is defined as: job duration / interval | ||||||
|  |         TARGET_EFFICIENCY = (0.8..0.98).freeze | ||||||
|  | 
 | ||||||
|  |         # Lower and upper bound for the batch size | ||||||
|  |         ALLOWED_BATCH_SIZE = (1_000..1_000_000).freeze | ||||||
|  | 
 | ||||||
|  |         # Use this batch_size multiplier to increase batch size | ||||||
|  |         INCREASE_MULTIPLIER = 1.1 | ||||||
|  | 
 | ||||||
|  |         # Use this batch_size multiplier to decrease batch size | ||||||
|  |         DECREASE_MULTIPLIER = 0.8 | ||||||
|  | 
 | ||||||
|  |         attr_reader :migration, :number_of_jobs | ||||||
|  | 
 | ||||||
|  |         def initialize(migration, number_of_jobs: 10) | ||||||
|  |           @migration = migration | ||||||
|  |           @number_of_jobs = number_of_jobs | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         def optimize! | ||||||
|  |           return unless Feature.enabled?(:optimize_batched_migrations, type: :ops) | ||||||
|  | 
 | ||||||
|  |           if multiplier = batch_size_multiplier | ||||||
|  |             migration.batch_size = (migration.batch_size * multiplier).to_i.clamp(ALLOWED_BATCH_SIZE) | ||||||
|  |             migration.save! | ||||||
|  |           end | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         private | ||||||
|  | 
 | ||||||
|  |         def batch_size_multiplier | ||||||
|  |           efficiency = migration.smoothed_time_efficiency(number_of_jobs: number_of_jobs) | ||||||
|  | 
 | ||||||
|  |           return unless efficiency | ||||||
|  | 
 | ||||||
|  |           if TARGET_EFFICIENCY.include?(efficiency) | ||||||
|  |             # We hit the range - no change | ||||||
|  |             nil | ||||||
|  |           elsif efficiency > TARGET_EFFICIENCY.max | ||||||
|  |             # We're above the range - decrease by 20% | ||||||
|  |             DECREASE_MULTIPLIER | ||||||
|  |           else | ||||||
|  |             # We're below the range - increase by 10% | ||||||
|  |             INCREASE_MULTIPLIER | ||||||
|  |           end | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -15,10 +15,22 @@ module Gitlab | ||||||
|           succeeded: 3 |           succeeded: 3 | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  |         scope :successful_in_execution_order, -> { where.not(finished_at: nil).succeeded.order(:finished_at) } | ||||||
|  | 
 | ||||||
|         delegate :aborted?, :job_class, :table_name, :column_name, :job_arguments, |         delegate :aborted?, :job_class, :table_name, :column_name, :job_arguments, | ||||||
|           to: :batched_migration, prefix: :migration |           to: :batched_migration, prefix: :migration | ||||||
| 
 | 
 | ||||||
|         attribute :pause_ms, :integer, default: 100 |         attribute :pause_ms, :integer, default: 100 | ||||||
|  | 
 | ||||||
|  |         def time_efficiency | ||||||
|  |           return unless succeeded? | ||||||
|  |           return unless finished_at && started_at | ||||||
|  | 
 | ||||||
|  |           duration = finished_at - started_at | ||||||
|  | 
 | ||||||
|  |           # TODO: Switch to individual job interval (prereq: https://gitlab.com/gitlab-org/gitlab/-/issues/328801) | ||||||
|  |           duration.to_f / batched_migration.interval | ||||||
|  |         end | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  | @ -76,6 +76,30 @@ module Gitlab | ||||||
|             migration_identifier: "%s/%s.%s" % [job_class_name, table_name, column_name] |             migration_identifier: "%s/%s.%s" % [job_class_name, table_name, column_name] | ||||||
|           } |           } | ||||||
|         end |         end | ||||||
|  | 
 | ||||||
|  |         def smoothed_time_efficiency(number_of_jobs: 10, alpha: 0.2) | ||||||
|  |           jobs = batched_jobs.successful_in_execution_order.reverse_order.limit(number_of_jobs) | ||||||
|  | 
 | ||||||
|  |           return if jobs.size < number_of_jobs | ||||||
|  | 
 | ||||||
|  |           efficiencies = jobs.map(&:time_efficiency).reject(&:nil?).each_with_index | ||||||
|  | 
 | ||||||
|  |           dividend = efficiencies.reduce(0) do |total, (job_eff, i)| | ||||||
|  |             total + job_eff * (1 - alpha)**i | ||||||
|  |           end | ||||||
|  | 
 | ||||||
|  |           divisor = efficiencies.reduce(0) do |total, (job_eff, i)| | ||||||
|  |             total + (1 - alpha)**i | ||||||
|  |           end | ||||||
|  | 
 | ||||||
|  |           return if divisor == 0 | ||||||
|  | 
 | ||||||
|  |           (dividend / divisor).round(2) | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         def optimize! | ||||||
|  |           BatchOptimizer.new(self).optimize! | ||||||
|  |         end | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  | @ -21,6 +21,8 @@ module Gitlab | ||||||
|         def run_migration_job(active_migration) |         def run_migration_job(active_migration) | ||||||
|           if next_batched_job = create_next_batched_job!(active_migration) |           if next_batched_job = create_next_batched_job!(active_migration) | ||||||
|             migration_wrapper.perform(next_batched_job) |             migration_wrapper.perform(next_batched_job) | ||||||
|  | 
 | ||||||
|  |             active_migration.optimize! | ||||||
|           else |           else | ||||||
|             finish_active_migration(active_migration) |             finish_active_migration(active_migration) | ||||||
|           end |           end | ||||||
|  |  | ||||||
|  | @ -10354,9 +10354,6 @@ msgstr "" | ||||||
| msgid "Date range cannot exceed %{maxDateRange} days." | msgid "Date range cannot exceed %{maxDateRange} days." | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
| msgid "Date range is greater than %{quarter_days} days" |  | ||||||
| msgstr "" |  | ||||||
| 
 |  | ||||||
| msgid "Date range must be shorter than %{max_range} days." | msgid "Date range must be shorter than %{max_range} days." | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
|  | @ -23257,15 +23254,6 @@ msgstr "" | ||||||
| msgid "Parameter \"job_id\" cannot exceed length of %{job_id_max_size}" | msgid "Parameter \"job_id\" cannot exceed length of %{job_id_max_size}" | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
| msgid "Parameter `from` must be specified" |  | ||||||
| msgstr "" |  | ||||||
| 
 |  | ||||||
| msgid "Parameter `interval` must be one of (\"%{valid_intervals}\")" |  | ||||||
| msgstr "" |  | ||||||
| 
 |  | ||||||
| msgid "Parameter `to` is before the `from` date" |  | ||||||
| msgstr "" |  | ||||||
| 
 |  | ||||||
| msgid "Parent" | msgid "Parent" | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
|  | @ -36566,9 +36554,6 @@ msgstr "" | ||||||
| msgid "You do not have any subscriptions yet" | msgid "You do not have any subscriptions yet" | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
| msgid "You do not have permission to access deployment frequencies" |  | ||||||
| msgstr "" |  | ||||||
| 
 |  | ||||||
| msgid "You do not have permission to access dora metrics." | msgid "You do not have permission to access dora metrics." | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -22,8 +22,9 @@ RSpec.describe RegistrationsController do | ||||||
|   describe '#create' do |   describe '#create' do | ||||||
|     let(:base_user_params) { { first_name: 'first', last_name: 'last', username: 'new_username', email: 'new@user.com', password: 'Any_password' } } |     let(:base_user_params) { { first_name: 'first', last_name: 'last', username: 'new_username', email: 'new@user.com', password: 'Any_password' } } | ||||||
|     let(:user_params) { { user: base_user_params } } |     let(:user_params) { { user: base_user_params } } | ||||||
|  |     let(:session_params) { {} } | ||||||
| 
 | 
 | ||||||
|     subject { post(:create, params: user_params) } |     subject { post(:create, params: user_params, session: session_params) } | ||||||
| 
 | 
 | ||||||
|     context '`blocked_pending_approval` state' do |     context '`blocked_pending_approval` state' do | ||||||
|       context 'when the `require_admin_approval_after_user_signup` setting is turned on' do |       context 'when the `require_admin_approval_after_user_signup` setting is turned on' do | ||||||
|  | @ -148,6 +149,26 @@ RSpec.describe RegistrationsController do | ||||||
|             expect { subject }.to have_enqueued_mail(DeviseMailer, :confirmation_instructions) |             expect { subject }.to have_enqueued_mail(DeviseMailer, :confirmation_instructions) | ||||||
|             expect(controller.current_user).to be_nil |             expect(controller.current_user).to be_nil | ||||||
|           end |           end | ||||||
|  | 
 | ||||||
|  |           context 'when registration is triggered from an accepted invite' do | ||||||
|  |             context 'when invite email matches email used on registration' do | ||||||
|  |               let(:session_params) { { invite_email: user_params.dig(:user, :email) } } | ||||||
|  | 
 | ||||||
|  |               it 'signs the user in without sending a confirmation email', :aggregate_failures do | ||||||
|  |                 expect { subject }.not_to have_enqueued_mail(DeviseMailer, :confirmation_instructions) | ||||||
|  |                 expect(controller.current_user).to be_confirmed | ||||||
|  |               end | ||||||
|  |             end | ||||||
|  | 
 | ||||||
|  |             context 'when invite email does not match the email used on registration' do | ||||||
|  |               let(:session_params) { { invite_email: 'bogus@email.com' } } | ||||||
|  | 
 | ||||||
|  |               it 'does not authenticate the user and sends a confirmation email', :aggregate_failures do | ||||||
|  |                 expect { subject }.to have_enqueued_mail(DeviseMailer, :confirmation_instructions) | ||||||
|  |                 expect(controller.current_user).to be_nil | ||||||
|  |               end | ||||||
|  |             end | ||||||
|  |           end | ||||||
|         end |         end | ||||||
| 
 | 
 | ||||||
|         context 'when soft email confirmation is enabled' do |         context 'when soft email confirmation is enabled' do | ||||||
|  | @ -161,6 +182,24 @@ RSpec.describe RegistrationsController do | ||||||
|             expect(controller.current_user).to be_present |             expect(controller.current_user).to be_present | ||||||
|             expect(response).to redirect_to(users_sign_up_welcome_path) |             expect(response).to redirect_to(users_sign_up_welcome_path) | ||||||
|           end |           end | ||||||
|  | 
 | ||||||
|  |           context 'when invite email matches email used on registration' do | ||||||
|  |             let(:session_params) { { invite_email: user_params.dig(:user, :email) } } | ||||||
|  | 
 | ||||||
|  |             it 'signs the user in without sending a confirmation email', :aggregate_failures do | ||||||
|  |               expect { subject }.not_to have_enqueued_mail(DeviseMailer, :confirmation_instructions) | ||||||
|  |               expect(controller.current_user).to be_confirmed | ||||||
|  |             end | ||||||
|  |           end | ||||||
|  | 
 | ||||||
|  |           context 'when invite email does not match the email used on registration' do | ||||||
|  |             let(:session_params) { { invite_email: 'bogus@email.com' } } | ||||||
|  | 
 | ||||||
|  |             it 'authenticates the user and sends a confirmation email without confirming', :aggregate_failures do | ||||||
|  |               expect { subject }.to have_enqueued_mail(DeviseMailer, :confirmation_instructions) | ||||||
|  |               expect(controller.current_user).not_to be_confirmed | ||||||
|  |             end | ||||||
|  |           end | ||||||
|         end |         end | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -90,7 +90,6 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do | ||||||
|     let(:new_user) { build_stubbed(:user) } |     let(:new_user) { build_stubbed(:user) } | ||||||
|     let(:invite_email) { new_user.email } |     let(:invite_email) { new_user.email } | ||||||
|     let(:group_invite) { create(:group_member, :invited, group: group, invite_email: invite_email, created_by: owner) } |     let(:group_invite) { create(:group_member, :invited, group: group, invite_email: invite_email, created_by: owner) } | ||||||
|     let!(:project_invite) { create(:project_member, :invited, project: project, invite_email: invite_email) } |  | ||||||
| 
 | 
 | ||||||
|     context 'when registering using invitation email' do |     context 'when registering using invitation email' do | ||||||
|       before do |       before do | ||||||
|  | @ -122,12 +121,6 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do | ||||||
| 
 | 
 | ||||||
|           expect(current_path).to eq(activity_group_path(group)) |           expect(current_path).to eq(activity_group_path(group)) | ||||||
|           expect(page).to have_content('You have been granted Owner access to group Owned.') |           expect(page).to have_content('You have been granted Owner access to group Owned.') | ||||||
| 
 |  | ||||||
|           visit group_path(group) |  | ||||||
|           expect(page).to have_content(group.full_name) |  | ||||||
| 
 |  | ||||||
|           visit project_path(project) |  | ||||||
|           expect(page).to have_content(project.name) |  | ||||||
|         end |         end | ||||||
| 
 | 
 | ||||||
|         context 'the user sign-up using a different email address' do |         context 'the user sign-up using a different email address' do | ||||||
|  | @ -150,18 +143,11 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do | ||||||
|             allow(User).to receive(:allow_unconfirmed_access_for).and_return 0 |             allow(User).to receive(:allow_unconfirmed_access_for).and_return 0 | ||||||
|           end |           end | ||||||
| 
 | 
 | ||||||
|           it 'signs up and redirects to root page with all the project/groups invitation automatically accepted' do |           it 'signs up and redirects to the group activity page with all the project/groups invitation automatically accepted' do | ||||||
|             fill_in_sign_up_form(new_user) |             fill_in_sign_up_form(new_user) | ||||||
|             confirm_email(new_user) |  | ||||||
|             fill_in_sign_in_form(new_user) |  | ||||||
|             fill_in_welcome_form |             fill_in_welcome_form | ||||||
| 
 | 
 | ||||||
|             expect(current_path).to eq(root_path) |             expect(current_path).to eq(activity_group_path(group)) | ||||||
|             expect(page).to have_content(project.full_name) |  | ||||||
| 
 |  | ||||||
|             visit group_path(group) |  | ||||||
| 
 |  | ||||||
|             expect(page).to have_content(group.full_name) |  | ||||||
|           end |           end | ||||||
|         end |         end | ||||||
| 
 | 
 | ||||||
|  | @ -170,29 +156,14 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do | ||||||
|             allow(User).to receive(:allow_unconfirmed_access_for).and_return 2.days |             allow(User).to receive(:allow_unconfirmed_access_for).and_return 2.days | ||||||
|           end |           end | ||||||
| 
 | 
 | ||||||
|           it 'signs up and redirects to root page with all the project/groups invitation automatically accepted' do |           it 'signs up and redirects to to the group activity page with all the project/groups invitation automatically accepted' do | ||||||
|             fill_in_sign_up_form(new_user) |             fill_in_sign_up_form(new_user) | ||||||
|             fill_in_welcome_form |             fill_in_welcome_form | ||||||
|             confirm_email(new_user) |  | ||||||
| 
 | 
 | ||||||
|             expect(current_path).to eq(root_path) |             expect(current_path).to eq(activity_group_path(group)) | ||||||
|             expect(page).to have_content(project.full_name) |  | ||||||
| 
 |  | ||||||
|             visit group_path(group) |  | ||||||
| 
 |  | ||||||
|             expect(page).to have_content(group.full_name) |  | ||||||
|           end |           end | ||||||
|         end |         end | ||||||
| 
 | 
 | ||||||
|         it "doesn't accept invitations until the user confirms their email" do |  | ||||||
|           fill_in_sign_up_form(new_user) |  | ||||||
|           fill_in_welcome_form |  | ||||||
|           sign_in(owner) |  | ||||||
| 
 |  | ||||||
|           visit project_project_members_path(project) |  | ||||||
|           expect(page).to have_content 'Invited' |  | ||||||
|         end |  | ||||||
| 
 |  | ||||||
|         context 'the user sign-up using a different email address' do |         context 'the user sign-up using a different email address' do | ||||||
|           let(:invite_email) { build_stubbed(:user).email } |           let(:invite_email) { build_stubbed(:user).email } | ||||||
| 
 | 
 | ||||||
|  | @ -202,7 +173,7 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do | ||||||
|               allow(User).to receive(:allow_unconfirmed_access_for).and_return 0 |               allow(User).to receive(:allow_unconfirmed_access_for).and_return 0 | ||||||
|             end |             end | ||||||
| 
 | 
 | ||||||
|             it 'signs up and redirects to the invitation page' do |             it 'signs up and redirects to the group activity page' do | ||||||
|               fill_in_sign_up_form(new_user) |               fill_in_sign_up_form(new_user) | ||||||
|               confirm_email(new_user) |               confirm_email(new_user) | ||||||
|               fill_in_sign_in_form(new_user) |               fill_in_sign_in_form(new_user) | ||||||
|  | @ -218,7 +189,7 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do | ||||||
|               allow(User).to receive(:allow_unconfirmed_access_for).and_return 2.days |               allow(User).to receive(:allow_unconfirmed_access_for).and_return 2.days | ||||||
|             end |             end | ||||||
| 
 | 
 | ||||||
|             it 'signs up and redirects to the invitation page' do |             it 'signs up and redirects to the group activity page' do | ||||||
|               fill_in_sign_up_form(new_user) |               fill_in_sign_up_form(new_user) | ||||||
|               fill_in_welcome_form |               fill_in_welcome_form | ||||||
| 
 | 
 | ||||||
|  | @ -282,7 +253,7 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do | ||||||
|         visit invite_path(group_invite.raw_invite_token) |         visit invite_path(group_invite.raw_invite_token) | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       it 'grants access and redirects to group page' do |       it 'grants access and redirects to the group activity page' do | ||||||
|         expect(group.users.include?(user)).to be false |         expect(group.users.include?(user)).to be false | ||||||
| 
 | 
 | ||||||
|         page.click_link 'Accept invitation' |         page.click_link 'Accept invitation' | ||||||
|  |  | ||||||
|  | @ -125,6 +125,7 @@ RSpec.describe 'File blob', :js do | ||||||
| 
 | 
 | ||||||
|         page.within '.project-refs-form' do |         page.within '.project-refs-form' do | ||||||
|           click_link ref_name |           click_link ref_name | ||||||
|  |           wait_for_requests | ||||||
|         end |         end | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|  | @ -170,6 +171,27 @@ RSpec.describe 'File blob', :js do | ||||||
|           expect(page).not_to have_css('.hll') |           expect(page).not_to have_css('.hll') | ||||||
|         end |         end | ||||||
|       end |       end | ||||||
|  | 
 | ||||||
|  |       context 'sucessfully change ref of similar name' do | ||||||
|  |         before do | ||||||
|  |           project.repository.create_branch('dev') | ||||||
|  |           project.repository.create_branch('development') | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         it 'switch ref from longer to shorter ref name' do | ||||||
|  |           visit_blob('files/js/application.js', ref: 'development') | ||||||
|  |           switch_ref_to('dev') | ||||||
|  | 
 | ||||||
|  |           expect(page.find('.file-title-name').text).to eq('application.js') | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         it 'switch ref from shorter to longer ref name' do | ||||||
|  |           visit_blob('files/js/application.js', ref: 'dev') | ||||||
|  |           switch_ref_to('development') | ||||||
|  | 
 | ||||||
|  |           expect(page.find('.file-title-name').text).to eq('application.js') | ||||||
|  |         end | ||||||
|  |       end | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     context 'visiting with a line number anchor' do |     context 'visiting with a line number anchor' do | ||||||
|  |  | ||||||
|  | @ -160,5 +160,62 @@ RSpec.describe DeploymentsFinder do | ||||||
|         end |         end | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|  | 
 | ||||||
|  |     context 'at group scope' do | ||||||
|  |       let_it_be(:group) { create(:group) } | ||||||
|  |       let_it_be(:subgroup) { create(:group, parent: group) } | ||||||
|  | 
 | ||||||
|  |       let_it_be(:group_project_1) { create(:project, :public, :test_repo, group: group) } | ||||||
|  |       let_it_be(:group_project_2) { create(:project, :public, :test_repo, group: group) } | ||||||
|  |       let_it_be(:subgroup_project_1) { create(:project, :public, :test_repo, group: subgroup) } | ||||||
|  |       let(:base_params) { { group: group } } | ||||||
|  | 
 | ||||||
|  |       describe 'ordering' do | ||||||
|  |         using RSpec::Parameterized::TableSyntax | ||||||
|  | 
 | ||||||
|  |         let(:params) { { **base_params, order_by: order_by, sort: sort } } | ||||||
|  | 
 | ||||||
|  |         let!(:group_project_1_deployment) { create(:deployment, :success, project: group_project_1, iid: 11, ref: 'master', created_at: 2.days.ago, updated_at: Time.now, finished_at: Time.now) } | ||||||
|  |         let!(:group_project_2_deployment) { create(:deployment, :success, project: group_project_2, iid: 12, ref: 'feature', created_at: 1.day.ago, updated_at: 2.hours.ago, finished_at: 2.hours.ago) } | ||||||
|  |         let!(:subgroup_project_1_deployment) { create(:deployment, :success, project: subgroup_project_1, iid: 8, ref: 'video', created_at: Time.now, updated_at: 1.hour.ago, finished_at: 1.hour.ago) } | ||||||
|  | 
 | ||||||
|  |         where(:order_by, :sort) do | ||||||
|  |           'created_at'  | 'asc' | ||||||
|  |           'created_at'  | 'desc' | ||||||
|  |           'id'          | 'asc' | ||||||
|  |           'id'          | 'desc' | ||||||
|  |           'iid'         | 'asc' | ||||||
|  |           'iid'         | 'desc' | ||||||
|  |           'ref'         | 'asc' | ||||||
|  |           'ref'         | 'desc' | ||||||
|  |           'updated_at'  | 'asc' | ||||||
|  |           'updated_at'  | 'desc' | ||||||
|  |           'finished_at' | 'asc' | ||||||
|  |           'finished_at' | 'desc' | ||||||
|  |           'invalid'     | 'asc' | ||||||
|  |           'iid'         | 'err' | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         with_them do | ||||||
|  |           it 'returns the deployments unordered' do | ||||||
|  |             expect(subject.to_a).to contain_exactly(group_project_1_deployment, | ||||||
|  |                                                     group_project_2_deployment, | ||||||
|  |                                                     subgroup_project_1_deployment) | ||||||
|  |           end | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it 'avoids N+1 queries' do | ||||||
|  |         execute_queries = -> { described_class.new({ group: group }).execute.first } | ||||||
|  |         control_count = ActiveRecord::QueryRecorder.new { execute_queries }.count | ||||||
|  | 
 | ||||||
|  |         new_project = create(:project, :repository, group: group) | ||||||
|  |         new_env = create(:environment, project: new_project, name: "production") | ||||||
|  |         create_list(:deployment, 2, status: :success, project: new_project, environment: new_env) | ||||||
|  |         group.reload | ||||||
|  | 
 | ||||||
|  |         expect { execute_queries }.not_to exceed_query_limit(control_count) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -0,0 +1,69 @@ | ||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | require 'spec_helper' | ||||||
|  | 
 | ||||||
|  | RSpec.describe Gitlab::Database::BackgroundMigration::BatchOptimizer do | ||||||
|  |   describe '#optimize' do | ||||||
|  |     subject { described_class.new(migration, number_of_jobs: number_of_jobs).optimize! } | ||||||
|  | 
 | ||||||
|  |     let(:migration) { create(:batched_background_migration, batch_size: batch_size, sub_batch_size: 100, interval: 120) } | ||||||
|  | 
 | ||||||
|  |     let(:batch_size) { 10_000 } | ||||||
|  | 
 | ||||||
|  |     let_it_be(:number_of_jobs) { 5 } | ||||||
|  | 
 | ||||||
|  |     def mock_efficiency(eff) | ||||||
|  |       expect(migration).to receive(:smoothed_time_efficiency).with(number_of_jobs: number_of_jobs).and_return(eff) | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'with unknown time efficiency, it keeps the batch size' do | ||||||
|  |       mock_efficiency(nil) | ||||||
|  | 
 | ||||||
|  |       expect { subject }.not_to change { migration.reload.batch_size } | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'with a time efficiency of 95%, it keeps the batch size' do | ||||||
|  |       mock_efficiency(0.95) | ||||||
|  | 
 | ||||||
|  |       expect { subject }.not_to change { migration.reload.batch_size } | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'with a time efficiency of 90%, it keeps the batch size' do | ||||||
|  |       mock_efficiency(0.9) | ||||||
|  | 
 | ||||||
|  |       expect { subject }.not_to change { migration.reload.batch_size } | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'with a time efficiency of 70%, it increases the batch size by 10%' do | ||||||
|  |       mock_efficiency(0.7) | ||||||
|  | 
 | ||||||
|  |       expect { subject }.to change { migration.reload.batch_size }.from(10_000).to(11_000) | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'with a time efficiency of 110%, it decreases the batch size by 20%' do | ||||||
|  |       mock_efficiency(1.1) | ||||||
|  | 
 | ||||||
|  |       expect { subject }.to change { migration.reload.batch_size }.from(10_000).to(8_000) | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     context 'reaching the upper limit for the batch size' do | ||||||
|  |       let(:batch_size) { 950_000 } | ||||||
|  | 
 | ||||||
|  |       it 'caps the batch size at 10M' do | ||||||
|  |         mock_efficiency(0.7) | ||||||
|  | 
 | ||||||
|  |         expect { subject }.to change { migration.reload.batch_size }.to(1_000_000) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     context 'reaching the lower limit for the batch size' do | ||||||
|  |       let(:batch_size) { 1_050 } | ||||||
|  | 
 | ||||||
|  |       it 'caps the batch size at 1k' do | ||||||
|  |         mock_efficiency(1.1) | ||||||
|  | 
 | ||||||
|  |         expect { subject }.to change { migration.reload.batch_size }.to(1_000) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -47,4 +47,55 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  | 
 | ||||||
|  |   describe '#time_efficiency' do | ||||||
|  |     subject { job.time_efficiency } | ||||||
|  | 
 | ||||||
|  |     let(:migration) { build(:batched_background_migration, interval: 120.seconds) } | ||||||
|  |     let(:job) { build(:batched_background_migration_job, status: :succeeded, batched_migration: migration) } | ||||||
|  | 
 | ||||||
|  |     context 'when job has not yet succeeded' do | ||||||
|  |       let(:job) { build(:batched_background_migration_job, status: :running) } | ||||||
|  | 
 | ||||||
|  |       it 'returns nil' do | ||||||
|  |         expect(subject).to be_nil | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     context 'when finished_at is not set' do | ||||||
|  |       it 'returns nil' do | ||||||
|  |         job.started_at = Time.zone.now | ||||||
|  | 
 | ||||||
|  |         expect(subject).to be_nil | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     context 'when started_at is not set' do | ||||||
|  |       it 'returns nil' do | ||||||
|  |         job.finished_at = Time.zone.now | ||||||
|  | 
 | ||||||
|  |         expect(subject).to be_nil | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     context 'when job has finished' do | ||||||
|  |       it 'returns ratio of duration to interval, here: 0.5' do | ||||||
|  |         freeze_time do | ||||||
|  |           job.started_at = Time.zone.now - migration.interval / 2 | ||||||
|  |           job.finished_at = Time.zone.now | ||||||
|  | 
 | ||||||
|  |           expect(subject).to eq(0.5) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it 'returns ratio of duration to interval, here: 1' do | ||||||
|  |         freeze_time do | ||||||
|  |           job.started_at = Time.zone.now - migration.interval | ||||||
|  |           job.finished_at = Time.zone.now | ||||||
|  | 
 | ||||||
|  |           expect(subject).to eq(1) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -50,6 +50,15 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do | ||||||
|             batch_size: migration.batch_size, |             batch_size: migration.batch_size, | ||||||
|             sub_batch_size: migration.sub_batch_size) |             sub_batch_size: migration.sub_batch_size) | ||||||
|         end |         end | ||||||
|  | 
 | ||||||
|  |         it 'optimizes the migration after executing the job' do | ||||||
|  |           migration.update!(min_value: event1.id, max_value: event2.id) | ||||||
|  | 
 | ||||||
|  |           expect(migration_wrapper).to receive(:perform).ordered | ||||||
|  |           expect(migration).to receive(:optimize!).ordered | ||||||
|  | 
 | ||||||
|  |           runner.run_migration_job(migration) | ||||||
|  |         end | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       context 'when the batch maximum exceeds the migration maximum' do |       context 'when the batch maximum exceeds the migration maximum' do | ||||||
|  |  | ||||||
|  | @ -232,4 +232,96 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m | ||||||
|       expect(batched_migration.prometheus_labels).to eq(labels) |       expect(batched_migration.prometheus_labels).to eq(labels) | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  | 
 | ||||||
|  |   describe '#smoothed_time_efficiency' do | ||||||
|  |     let(:migration) { create(:batched_background_migration, interval: 120.seconds) } | ||||||
|  |     let(:end_time) { Time.zone.now } | ||||||
|  | 
 | ||||||
|  |     around do |example| | ||||||
|  |       freeze_time do | ||||||
|  |         example.run | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     let(:common_attrs) do | ||||||
|  |       { | ||||||
|  |         status: :succeeded, | ||||||
|  |         batched_migration: migration, | ||||||
|  |         finished_at: end_time | ||||||
|  |       } | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     context 'when there are not enough jobs' do | ||||||
|  |       subject { migration.smoothed_time_efficiency(number_of_jobs: 10) } | ||||||
|  | 
 | ||||||
|  |       it 'returns nil' do | ||||||
|  |         create_list(:batched_background_migration_job, 9, **common_attrs) | ||||||
|  | 
 | ||||||
|  |         expect(subject).to be_nil | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     context 'when there are enough jobs' do | ||||||
|  |       subject { migration.smoothed_time_efficiency(number_of_jobs: number_of_jobs) } | ||||||
|  | 
 | ||||||
|  |       let!(:jobs) { create_list(:batched_background_migration_job, number_of_jobs, **common_attrs.merge(batched_migration: migration)) } | ||||||
|  |       let(:number_of_jobs) { 10 } | ||||||
|  | 
 | ||||||
|  |       before do | ||||||
|  |         expect(migration).to receive_message_chain(:batched_jobs, :successful_in_execution_order, :reverse_order, :limit).with(no_args).with(no_args).with(number_of_jobs).and_return(jobs) | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       def mock_efficiencies(*effs) | ||||||
|  |         effs.each_with_index do |eff, i| | ||||||
|  |           expect(jobs[i]).to receive(:time_efficiency).and_return(eff) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'example 1: increasing trend, but only recently crossed threshold' do | ||||||
|  |         it 'returns the smoothed time efficiency' do | ||||||
|  |           mock_efficiencies(1.1, 1, 0.95, 0.9, 0.8, 0.95, 0.9, 0.8, 0.9, 0.95) | ||||||
|  | 
 | ||||||
|  |           expect(subject).to be_within(0.05).of(0.95) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'example 2: increasing trend, crossed threshold a while ago' do | ||||||
|  |         it 'returns the smoothed time efficiency' do | ||||||
|  |           mock_efficiencies(1.2, 1.1, 1, 1, 1.1, 1, 0.95, 0.9, 0.95, 0.9) | ||||||
|  | 
 | ||||||
|  |           expect(subject).to be_within(0.05).of(1.1) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'example 3: decreasing trend, but only recently crossed threshold' do | ||||||
|  |         it 'returns the smoothed time efficiency' do | ||||||
|  |           mock_efficiencies(0.9, 0.95, 1, 1.2, 1.1, 1.2, 1.1, 1.0, 1.1, 1.0) | ||||||
|  | 
 | ||||||
|  |           expect(subject).to be_within(0.05).of(1.0) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'example 4: latest run spiked' do | ||||||
|  |         it 'returns the smoothed time efficiency' do | ||||||
|  |           mock_efficiencies(1.2, 0.9, 0.8, 0.9, 0.95, 0.9, 0.92, 0.9, 0.95, 0.9) | ||||||
|  | 
 | ||||||
|  |           expect(subject).to be_within(0.02).of(0.96) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   describe '#optimize!' do | ||||||
|  |     subject { batched_migration.optimize! } | ||||||
|  | 
 | ||||||
|  |     let(:batched_migration) { create(:batched_background_migration) } | ||||||
|  |     let(:optimizer) { instance_double('Gitlab::Database::BackgroundMigration::BatchOptimizer') } | ||||||
|  | 
 | ||||||
|  |     it 'calls the BatchOptimizer' do | ||||||
|  |       expect(Gitlab::Database::BackgroundMigration::BatchOptimizer).to receive(:new).with(batched_migration).and_return(optimizer) | ||||||
|  |       expect(optimizer).to receive(:optimize!) | ||||||
|  | 
 | ||||||
|  |       subject | ||||||
|  |     end | ||||||
|  |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -0,0 +1,73 @@ | ||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | require 'spec_helper' | ||||||
|  | 
 | ||||||
|  | RSpec.describe Users::RegistrationsBuildService do | ||||||
|  |   describe '#execute' do | ||||||
|  |     let(:base_params) { build_stubbed(:user).slice(:first_name, :last_name, :username, :email, :password) } | ||||||
|  |     let(:skip_param) { {} } | ||||||
|  |     let(:params) { base_params.merge(skip_param) } | ||||||
|  | 
 | ||||||
|  |     subject(:service) { described_class.new(nil, params) } | ||||||
|  | 
 | ||||||
|  |     before do | ||||||
|  |       stub_application_setting(signup_enabled?: true) | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     context 'when automatic user confirmation is not enabled' do | ||||||
|  |       before do | ||||||
|  |         stub_application_setting(send_user_confirmation_email: true) | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'when skip_confirmation is true' do | ||||||
|  |         let(:skip_param) { { skip_confirmation: true } } | ||||||
|  | 
 | ||||||
|  |         it 'confirms the user' do | ||||||
|  |           expect(service.execute).to be_confirmed | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'when skip_confirmation is not set' do | ||||||
|  |         it 'does not confirm the user' do | ||||||
|  |           expect(service.execute).not_to be_confirmed | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'when skip_confirmation is false' do | ||||||
|  |         let(:skip_param) { { skip_confirmation: false } } | ||||||
|  | 
 | ||||||
|  |         it 'does not confirm the user' do | ||||||
|  |           expect(service.execute).not_to be_confirmed | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     context 'when automatic user confirmation is enabled' do | ||||||
|  |       before do | ||||||
|  |         stub_application_setting(send_user_confirmation_email: false) | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'when skip_confirmation is true' do | ||||||
|  |         let(:skip_param) { { skip_confirmation: true } } | ||||||
|  | 
 | ||||||
|  |         it 'confirms the user' do | ||||||
|  |           expect(service.execute).to be_confirmed | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'when skip_confirmation is not set the application setting takes precedence' do | ||||||
|  |         it 'confirms the user' do | ||||||
|  |           expect(service.execute).to be_confirmed | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'when skip_confirmation is false the application setting takes precedence' do | ||||||
|  |         let(:skip_param) { { skip_confirmation: false } } | ||||||
|  | 
 | ||||||
|  |         it 'confirms the user' do | ||||||
|  |           expect(service.execute).to be_confirmed | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
		Loading…
	
		Reference in New Issue