summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitlab/merge_request_templates/Pipeline Configuration.md38
-rw-r--r--.rubocop_manual_todo.yml1
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock4
-rw-r--r--app/assets/javascripts/content_editor/components/top_toolbar.vue9
-rw-r--r--app/assets/javascripts/content_editor/extensions/strike.js9
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js2
-rw-r--r--app/assets/javascripts/environments/components/deploy_board.vue4
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_item.vue13
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_list_root.vue32
-rw-r--r--app/assets/javascripts/issues_list/components/issue_card_time_info.vue7
-rw-r--r--app/assets/javascripts/issues_list/components/issues_list_app.vue132
-rw-r--r--app/assets/javascripts/issues_list/constants.js5
-rw-r--r--app/assets/javascripts/issues_list/index.js13
-rw-r--r--app/assets/javascripts/issues_list/queries/get_issues.query.graphql45
-rw-r--r--app/assets/javascripts/issues_list/queries/issue.fragment.graphql51
-rw-r--r--app/assets/javascripts/jira_connect/index.js2
-rw-r--r--app/assets/javascripts/performance_bar/index.js2
-rw-r--r--app/assets/javascripts/runner/components/runner_manual_setup_help.vue42
-rw-r--r--app/assets/javascripts/runner/components/runner_registration_token_reset.vue83
-rw-r--r--app/assets/javascripts/runner/graphql/runners_registration_token_reset.mutation.graphql6
-rw-r--r--app/assets/javascripts/runner/runner_list/runner_list_app.vue7
-rw-r--r--app/assets/javascripts/sentry/index.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue44
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue83
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue55
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql15
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js26
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js11
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js1
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue2
-rw-r--r--app/assets/javascripts/webpack.js3
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar_variant.scss54
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss114
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss18
-rw-r--r--app/controllers/admin/application_settings_controller.rb9
-rw-r--r--app/controllers/admin/cohorts_controller.rb23
-rw-r--r--app/controllers/admin/users_controller.rb24
-rw-r--r--app/controllers/confirmations_controller.rb2
-rw-r--r--app/controllers/projects/environments_controller.rb4
-rw-r--r--app/controllers/projects/merge_requests/content_controller.rb11
-rw-r--r--app/helpers/issues_helper.rb1
-rw-r--r--app/models/ability.rb67
-rw-r--r--app/models/analytics/cycle_analytics/project_level.rb1
-rw-r--r--app/models/ci/pipeline.rb2
-rw-r--r--app/models/clusters/platforms/kubernetes.rb6
-rw-r--r--app/models/container_expiration_policy.rb10
-rw-r--r--app/models/integration.rb4
-rw-r--r--app/models/issue.rb1
-rw-r--r--app/models/merge_request.rb3
-rw-r--r--app/models/packages/package.rb2
-rw-r--r--app/models/project.rb27
-rw-r--r--app/models/remote_mirror.rb3
-rw-r--r--app/models/user.rb15
-rw-r--r--app/policies/base_policy.rb6
-rw-r--r--app/policies/concerns/policy_actor.rb6
-rw-r--r--app/policies/global_policy.rb2
-rw-r--r--app/serializers/merge_request_poll_widget_entity.rb1
-rw-r--r--app/serializers/merge_request_widget_entity.rb2
-rw-r--r--app/services/environments/canary_ingress/update_service.rb4
-rw-r--r--app/services/merge_requests/refresh_service.rb10
-rw-r--r--app/services/packages/helm/process_file_service.rb97
-rw-r--r--app/services/projects/update_remote_mirror_service.rb8
-rw-r--r--app/services/users/update_service.rb1
-rw-r--r--app/views/admin/cohorts/_cohorts.html.haml (renamed from app/views/admin/users/_cohorts.html.haml)0
-rw-r--r--app/views/admin/cohorts/_cohorts_table.html.haml (renamed from app/views/admin/users/_cohorts_table.html.haml)0
-rw-r--r--app/views/admin/cohorts/index.html.haml (renamed from app/views/admin/users/cohorts.html.haml)2
-rw-r--r--app/views/admin/runners/show.html.haml14
-rw-r--r--app/views/admin/users/_tabs.html.haml2
-rw-r--r--app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml16
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml6
-rw-r--r--app/views/projects/merge_requests/show.html.haml2
-rw-r--r--app/workers/container_expiration_policy_worker.rb8
-rw-r--r--app/workers/web_hook_worker.rb2
-rw-r--r--config/feature_flags/development/block_external_fork_network_mirrors.yml8
-rw-r--r--config/feature_flags/development/disable_composer_callback.yml (renamed from config/feature_flags/development/load_balancing_for_web_hook_worker.yml)8
-rw-r--r--config/feature_flags/development/update_remote_mirror_inmemory.yml (renamed from config/feature_flags/development/canary_ingress_weight_control.yml)12
-rw-r--r--config/initializers/active_record_keyset_pagination.rb4
-rw-r--r--config/initializers/declarative_policy.rb2
-rw-r--r--config/routes/admin.rb6
-rw-r--r--config/sidekiq_queues.yml2
-rw-r--r--config/webpack.config.js9
-rw-r--r--db/migrate/20210608103230_add_issue_id_to_test_report.rb17
-rw-r--r--db/migrate/20210608103235_add_issue_index_to_test_report.rb17
-rw-r--r--db/migrate/20210608110752_change_column_null_test_report_requirement.rb17
-rw-r--r--db/migrate/20210608110760_add_requirement_test_reports_foreign_key.rb24
-rw-r--r--db/migrate/20210614143954_add_unique_index_for_helm_packages.rb18
-rw-r--r--db/schema_migrations/202106081032301
-rw-r--r--db/schema_migrations/202106081032351
-rw-r--r--db/schema_migrations/202106081107521
-rw-r--r--db/schema_migrations/202106081107601
-rw-r--r--db/schema_migrations/202106141439541
-rw-r--r--db/structure.sql13
-rw-r--r--doc/.vale/gitlab/Admin.yml4
-rw-r--r--doc/administration/database_load_balancing.md2
-rw-r--r--doc/administration/troubleshooting/kubernetes_cheat_sheet.md2
-rw-r--r--doc/api/graphql/reference/index.md6
-rw-r--r--doc/api/group_milestones.md5
-rw-r--r--doc/ci/runners/README.md2
-rw-r--r--doc/development/database/database_reviewer_guidelines.md2
-rw-r--r--doc/development/database/keyset_pagination.md251
-rw-r--r--doc/development/database/pagination_guidelines.md21
-rw-r--r--doc/development/documentation/styleguide/index.md20
-rw-r--r--doc/development/fe_guide/graphql.md64
-rw-r--r--doc/development/query_performance.md4
-rw-r--r--doc/development/sidekiq_style_guide.md67
-rw-r--r--doc/development/understanding_explain_plans.md149
-rw-r--r--doc/development/usage_ping/index.md31
-rw-r--r--doc/user/admin_area/analytics/usage_trends.md5
-rw-r--r--doc/user/admin_area/settings/email.md10
-rw-r--r--doc/user/admin_area/settings/img/file_template_admin_area.pngbin5624 -> 0 bytes
-rw-r--r--doc/user/admin_area/settings/img/file_template_admin_area_v14_0.pngbin0 -> 11252 bytes
-rw-r--r--doc/user/admin_area/settings/instance_template_repository.md2
-rw-r--r--doc/user/admin_area/settings/rate_limit_on_issues_creation.md8
-rw-r--r--doc/user/admin_area/settings/rate_limit_on_notes_creation.md8
-rw-r--r--doc/user/application_security/container_scanning/index.md52
-rw-r--r--doc/user/application_security/sast/index.md4
-rw-r--r--doc/user/gitlab_com/index.md197
-rw-r--r--doc/user/group/epics/epic_boards.md12
-rw-r--r--doc/user/packages/container_registry/index.md4
-rw-r--r--doc/user/project/clusters/index.md4
-rw-r--r--doc/user/project/description_templates.md7
-rw-r--r--doc/user/project/merge_requests/approvals/settings.md2
-rw-r--r--doc/user/project/time_tracking.md11
-rw-r--r--doc/user/shortcuts.md2
-rw-r--r--lib/api/members.rb2
-rw-r--r--lib/gitlab/auth.rb2
-rw-r--r--lib/gitlab/auth/current_user_mode.rb34
-rw-r--r--lib/gitlab/auth/user_access_denied_reason.rb4
-rw-r--r--lib/gitlab/ci/config/entry/job.rb2
-rw-r--r--lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml19
-rw-r--r--lib/gitlab/ci/templates/Security/DAST.latest.gitlab-ci.yml11
-rw-r--r--lib/gitlab/ci/yaml_processor/result.rb2
-rw-r--r--lib/gitlab/database/migrations/observers.rb3
-rw-r--r--lib/gitlab/database/migrations/observers/query_details.rb41
-rw-r--r--lib/gitlab/exclusive_lease.rb27
-rw-r--r--lib/gitlab/git/remote_mirror.rb6
-rw-r--r--lib/gitlab/gitaly_client/remote_service.rb11
-rw-r--r--lib/gitlab/lfs_token.rb2
-rw-r--r--lib/gitlab/pagination/keyset/paginator.rb9
-rw-r--r--lib/gitlab/safe_request_store.rb15
-rw-r--r--lib/sidebars/projects/menus/ci_cd_menu.rb4
-rw-r--r--locale/gitlab.pot12
-rw-r--r--qa/qa/specs/features/browser_ui/non_devops/performance_bar_spec.rb2
-rw-r--r--spec/controllers/admin/cohorts_controller_spec.rb8
-rw-r--r--spec/controllers/admin/users_controller_spec.rb6
-rw-r--r--spec/controllers/projects/merge_requests/content_controller_spec.rb11
-rw-r--r--spec/controllers/projects/protected_branches_controller_spec.rb6
-rw-r--r--spec/factories/integrations.rb6
-rw-r--r--spec/factories/packages/package_file.rb2
-rw-r--r--spec/factories/projects.rb2
-rw-r--r--spec/features/admin/admin_users_spec.rb6
-rw-r--r--spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb2
-rw-r--r--spec/features/projects/active_tabs_spec.rb51
-rw-r--r--spec/features/users/login_spec.rb14
-rw-r--r--spec/frontend/content_editor/components/top_toolbar_spec.js1
-rw-r--r--spec/frontend/environments/deploy_board_component_spec.js1
-rw-r--r--spec/frontend/fixtures/api_markdown.yml4
-rw-r--r--spec/frontend/fixtures/releases.rb3
-rw-r--r--spec/frontend/issuable_list/components/issuable_list_root_spec.js128
-rw-r--r--spec/frontend/issues_list/components/issue_card_time_info_spec.js10
-rw-r--r--spec/frontend/issues_list/components/issues_list_app_spec.js278
-rw-r--r--spec/frontend/issues_list/mock_data.js67
-rw-r--r--spec/frontend/runner/components/runner_manual_setup_help_spec.js39
-rw-r--r--spec/frontend/runner/components/runner_registration_token_reset_spec.js155
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js22
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js31
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js17
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js91
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js173
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js357
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js72
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js61
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js88
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js84
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js241
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js93
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js176
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js59
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js140
-rw-r--r--spec/helpers/issues_helper_spec.rb1
-rw-r--r--spec/lib/banzai/filter/references/external_issue_reference_filter_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/templates/templates_spec.rb9
-rw-r--r--spec/lib/gitlab/database/migrations/observers/query_details_spec.rb58
-rw-r--r--spec/lib/gitlab/exclusive_lease_spec.rb78
-rw-r--r--spec/lib/gitlab/git/remote_mirror_spec.rb25
-rw-r--r--spec/lib/gitlab/git_access_spec.rb24
-rw-r--r--spec/lib/gitlab/gitaly_client/remote_service_spec.rb28
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml12
-rw-r--r--spec/lib/gitlab/pagination/keyset/paginator_spec.rb23
-rw-r--r--spec/lib/sidebars/projects/menus/external_wiki_menu_spec.rb2
-rw-r--r--spec/models/ability_spec.rb41
-rw-r--r--spec/models/ci/pipeline_spec.rb24
-rw-r--r--spec/models/clusters/platforms/kubernetes_spec.rb10
-rw-r--r--spec/models/container_expiration_policy_spec.rb16
-rw-r--r--spec/models/integrations/emails_on_push_spec.rb8
-rw-r--r--spec/models/integrations/flowdock_spec.rb20
-rw-r--r--spec/models/merge_request_spec.rb11
-rw-r--r--spec/models/packages/package_spec.rb20
-rw-r--r--spec/models/project_spec.rb51
-rw-r--r--spec/models/remote_mirror_spec.rb41
-rw-r--r--spec/models/user_spec.rb64
-rw-r--r--spec/policies/base_policy_spec.rb30
-rw-r--r--spec/policies/global_policy_spec.rb24
-rw-r--r--spec/requests/api/graphql/group_query_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb12
-rw-r--r--spec/requests/api/import_bitbucket_server_spec.rb2
-rw-r--r--spec/requests/api/protected_branches_spec.rb4
-rw-r--r--spec/requests/api/services_spec.rb6
-rw-r--r--spec/requests/git_http_spec.rb62
-rw-r--r--spec/serializers/merge_request_poll_widget_entity_spec.rb18
-rw-r--r--spec/serializers/service_event_entity_spec.rb8
-rw-r--r--spec/serializers/service_field_entity_spec.rb7
-rw-r--r--spec/services/ci/create_pipeline_service/cross_project_pipeline_spec.rb2
-rw-r--r--spec/services/ci/create_pipeline_service_spec.rb1
-rw-r--r--spec/services/ci/pipeline_processing/shared_processing_service.rb2
-rw-r--r--spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb1
-rw-r--r--spec/services/environments/canary_ingress/update_service_spec.rb10
-rw-r--r--spec/services/packages/helm/extract_file_metadata_service_spec.rb4
-rw-r--r--spec/services/packages/helm/process_file_service_spec.rb107
-rw-r--r--spec/services/projects/update_remote_mirror_service_spec.rb21
-rw-r--r--spec/services/protected_branches/create_service_spec.rb2
-rw-r--r--spec/services/protected_branches/destroy_service_spec.rb2
-rw-r--r--spec/services/protected_branches/update_service_spec.rb2
-rw-r--r--spec/services/repositories/changelog_service_spec.rb2
-rw-r--r--spec/spec_helper.rb9
-rw-r--r--spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb2
-rw-r--r--spec/workers/container_expiration_policy_worker_spec.rb14
-rw-r--r--spec/workers/web_hook_worker_spec.rb1
239 files changed, 5105 insertions, 1090 deletions
diff --git a/.gitlab/merge_request_templates/Pipeline Configuration.md b/.gitlab/merge_request_templates/Pipeline Configuration.md
new file mode 100644
index 00000000000..920abf086cb
--- /dev/null
+++ b/.gitlab/merge_request_templates/Pipeline Configuration.md
@@ -0,0 +1,38 @@
+<!-- See Pipelines for the GitLab project: https://docs.gitlab.com/ee/development/pipelines.html -->
+<!-- When in doubt about a Pipeline configuration change, feel free to ping @gl-quality/eng-prod. -->
+
+## What does this MR do?
+
+<!-- Briefly describe what this MR is about -->
+
+## Related issues
+
+<!-- Link related issues below. -->
+
+## Check-list
+
+### Pre-merge
+
+Consider the effect of the changes in this merge request on the following:
+
+- [ ] Different [pipeline types](https://docs.gitlab.com/ee/development/pipelines.html#pipelines-for-merge-requests)
+- Non-canonical projects:
+ - [ ] `gitlab-foss`
+ - [ ] `security`
+ - [ ] `dev`
+ - [ ] personal forks
+- [ ] [Pipeline performance](https://about.gitlab.com/handbook/engineering/quality/performance-indicators/#average-merge-request-pipeline-duration-for-gitlab)
+
+**If new jobs are added:**
+
+- [ ] Change-related rules (e.g. frontend/backend/database file changes): _____
+- [ ] Frequency they are running (MRs, main branch, nightly, bi-hourly): _____
+- [ ] Add a duration chart to https://app.periscopedata.com/app/gitlab/652085/Engineering-Productivity---Pipeline-Build-Durations if there are new jobs added to merge request pipelines
+
+This will help keep track of expected cost increases to the [GitLab project average pipeline cost per merge request](https://about.gitlab.com/handbook/engineering/quality/performance-indicators/#gitlab-project-average-pipeline-cost-per-merge-request) RPI
+
+### Post-merge
+
+- [ ] Consider communicating these changes to the broader team following the [communication guideline for pipeline changes](https://about.gitlab.com/handbook/engineering/quality/engineering-productivity-team/#pipeline-changes)
+
+/label ~tooling ~"tooling::pipelines" ~"Engineering Productivity"
diff --git a/.rubocop_manual_todo.yml b/.rubocop_manual_todo.yml
index dcd6dcf4062..dae8084b656 100644
--- a/.rubocop_manual_todo.yml
+++ b/.rubocop_manual_todo.yml
@@ -13,7 +13,6 @@
# WIP See https://gitlab.com/gitlab-org/gitlab/-/issues/322903
Graphql/Descriptions:
Exclude:
- - 'ee/app/graphql/ee/types/list_limit_metric_enum.rb'
- 'ee/app/graphql/types/epic_state_enum.rb'
- 'ee/app/graphql/types/health_status_enum.rb'
- 'ee/app/graphql/types/iteration_state_enum.rb'
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index 9b197696144..ad5db8cde46 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-8a6d0e26de9d584941267d2b68c94b37bc30e092
+77d6f6e6bee63c41438ec5c186c10fa17b91fd7c
diff --git a/Gemfile b/Gemfile
index 3f4de069464..c55cea60e31 100644
--- a/Gemfile
+++ b/Gemfile
@@ -480,7 +480,7 @@ end
gem 'spamcheck', '~> 0.1.0'
# Gitaly GRPC protocol definitions
-gem 'gitaly', '~> 13.12.0.pre.rc1'
+gem 'gitaly', '~> 14.0.0.pre.rc2'
# KAS GRPC protocol definitions
gem 'kas-grpc', '~> 0.0.2'
diff --git a/Gemfile.lock b/Gemfile.lock
index 5cd2431a7d1..ef31d72a5dd 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -454,7 +454,7 @@ GEM
rails (>= 3.2.0)
git (1.7.0)
rchardet (~> 1.8)
- gitaly (13.12.0.pre.rc1)
+ gitaly (14.0.0.pre.rc2)
grpc (~> 1.0)
github-markup (1.7.0)
gitlab (4.16.1)
@@ -1483,7 +1483,7 @@ DEPENDENCIES
gettext (~> 3.3)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3)
- gitaly (~> 13.12.0.pre.rc1)
+ gitaly (~> 14.0.0.pre.rc2)
github-markup (~> 1.7.0)
gitlab-chronic (~> 0.10.5)
gitlab-dangerfiles (~> 2.1.2)
diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue
index 07fdd3147e2..d3363ce092b 100644
--- a/app/assets/javascripts/content_editor/components/top_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue
@@ -64,6 +64,15 @@ export default {
@execute="trackToolbarControlExecution"
/>
<toolbar-button
+ data-testid="strike"
+ content-type="strike"
+ icon-name="strikethrough"
+ editor-command="toggleStrike"
+ :label="__('Strikethrough')"
+ :tiptap-editor="contentEditor.tiptapEditor"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
data-testid="code"
content-type="code"
icon-name="code"
diff --git a/app/assets/javascripts/content_editor/extensions/strike.js b/app/assets/javascripts/content_editor/extensions/strike.js
new file mode 100644
index 00000000000..6f228e00994
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/strike.js
@@ -0,0 +1,9 @@
+import { Strike } from '@tiptap/extension-strike';
+
+export const tiptapExtension = Strike;
+export const serializer = {
+ open: '~~',
+ close: '~~',
+ mixable: true,
+ expelEnclosingWhitespace: true,
+};
diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js
index df45287e6cb..8a54da6f57d 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -19,6 +19,7 @@ import * as Link from '../extensions/link';
import * as ListItem from '../extensions/list_item';
import * as OrderedList from '../extensions/ordered_list';
import * as Paragraph from '../extensions/paragraph';
+import * as Strike from '../extensions/strike';
import * as Text from '../extensions/text';
import buildSerializerConfig from './build_serializer_config';
import { ContentEditor } from './content_editor';
@@ -44,6 +45,7 @@ const builtInContentEditorExtensions = [
ListItem,
OrderedList,
Paragraph,
+ Strike,
Text,
];
diff --git a/app/assets/javascripts/environments/components/deploy_board.vue b/app/assets/javascripts/environments/components/deploy_board.vue
index f9c4660036b..217cea051b7 100644
--- a/app/assets/javascripts/environments/components/deploy_board.vue
+++ b/app/assets/javascripts/environments/components/deploy_board.vue
@@ -66,9 +66,7 @@ export default {
return this.isEmpty;
},
canRenderCanaryWeight() {
- return (
- this.glFeatures.canaryIngressWeightControl && !isEmpty(this.deployBoardData.canary_ingress)
- );
+ return !isEmpty(this.deployBoardData.canary_ingress);
},
instanceCount() {
const { instances } = this.deployBoardData;
diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue
index 348dc054f57..20d1dce3905 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_item.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue
@@ -50,6 +50,9 @@ export default {
},
},
computed: {
+ issuableId() {
+ return getIdFromGraphQLId(this.issuable.id);
+ },
createdInPastDay() {
const createdSecondsAgo = differenceInSeconds(new Date(this.issuable.createdAt), new Date());
return createdSecondsAgo < SECONDS_IN_DAY;
@@ -61,7 +64,7 @@ export default {
return this.issuable.gitlabWebUrl || this.issuable.webUrl;
},
authorId() {
- return getIdFromGraphQLId(`${this.author.id}`);
+ return getIdFromGraphQLId(this.author.id);
},
isIssuableUrlExternal() {
return isExternal(this.webUrl);
@@ -70,10 +73,10 @@ export default {
return this.issuable.labels?.nodes || this.issuable.labels || [];
},
labelIdsString() {
- return JSON.stringify(this.labels.map((label) => label.id));
+ return JSON.stringify(this.labels.map((label) => getIdFromGraphQLId(label.id)));
},
assignees() {
- return this.issuable.assignees || [];
+ return this.issuable.assignees?.nodes || this.issuable.assignees || [];
},
createdAt() {
return sprintf(__('created %{timeAgo}'), {
@@ -157,7 +160,7 @@ export default {
<template>
<li
- :id="`issuable_${issuable.id}`"
+ :id="`issuable_${issuableId}`"
class="issue gl-px-5!"
:class="{ closed: issuable.closedAt, today: createdInPastDay }"
:data-labels="labelIdsString"
@@ -167,7 +170,7 @@ export default {
<gl-form-checkbox
class="gl-mr-0"
:checked="checked"
- :data-id="issuable.id"
+ :data-id="issuableId"
@input="$emit('checked-input', $event)"
>
<span class="gl-sr-only">{{ issuable.title }}</span>
diff --git a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
index 45584205be0..a19c76cfe3f 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
@@ -1,7 +1,7 @@
<script>
-import { GlSkeletonLoading, GlPagination } from '@gitlab/ui';
+import { GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui';
import { uniqueId } from 'lodash';
-
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
@@ -19,6 +19,7 @@ export default {
tag: 'ul',
},
components: {
+ GlKeysetPagination,
GlSkeletonLoading,
IssuableTabs,
FilteredSearchBar,
@@ -140,6 +141,21 @@ export default {
required: false,
default: false,
},
+ useKeysetPagination: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ hasNextPage: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ hasPreviousPage: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -211,7 +227,7 @@ export default {
},
methods: {
issuableId(issuable) {
- return issuable.id || issuable.iid || uniqueId();
+ return getIdFromGraphQLId(issuable.id) || issuable.iid || uniqueId();
},
issuableChecked(issuable) {
return this.checkedIssuables[this.issuableId(issuable)]?.checked;
@@ -315,8 +331,16 @@ export default {
<slot v-else name="empty-state"></slot>
</template>
+ <div v-if="showPaginationControls && useKeysetPagination" class="gl-text-center gl-mt-3">
+ <gl-keyset-pagination
+ :has-next-page="hasNextPage"
+ :has-previous-page="hasPreviousPage"
+ @next="$emit('next-page')"
+ @prev="$emit('previous-page')"
+ />
+ </div>
<gl-pagination
- v-if="showPaginationControls"
+ v-else-if="showPaginationControls"
:per-page="defaultPageSize"
:total-items="totalItems"
:value="currentPage"
diff --git a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue
index 8d00d337bac..70d73aca925 100644
--- a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue
+++ b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue
@@ -42,6 +42,9 @@ export default {
}
return __('Milestone');
},
+ milestoneLink() {
+ return this.issue.milestone.webPath || this.issue.milestone.webUrl;
+ },
dueDate() {
return this.issue.dueDate && dateInWords(new Date(this.issue.dueDate), true);
},
@@ -49,7 +52,7 @@ export default {
return isInPast(new Date(this.issue.dueDate));
},
timeEstimate() {
- return this.issue.timeStats?.humanTimeEstimate;
+ return this.issue.humanTimeEstimate || this.issue.timeStats?.humanTimeEstimate;
},
showHealthStatus() {
return this.hasIssuableHealthStatusFeature && this.issue.healthStatus;
@@ -85,7 +88,7 @@ export default {
class="issuable-milestone gl-display-none gl-sm-display-inline-block! gl-mr-3"
data-testid="issuable-milestone"
>
- <gl-link v-gl-tooltip :href="issue.milestone.webUrl" :title="milestoneDate">
+ <gl-link v-gl-tooltip :href="milestoneLink" :title="milestoneDate">
<gl-icon name="clock" />
{{ issue.milestone.title }}
</gl-link>
diff --git a/app/assets/javascripts/issues_list/components/issues_list_app.vue b/app/assets/javascripts/issues_list/components/issues_list_app.vue
index d5cab77f26c..dbf7717b248 100644
--- a/app/assets/javascripts/issues_list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue
@@ -9,7 +9,7 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
-import { toNumber } from 'lodash';
+import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
import createFlash from '~/flash';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
@@ -17,13 +17,12 @@ import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants';
import {
API_PARAM,
- apiSortParams,
CREATED_DESC,
i18n,
+ initialPageParams,
MAX_LIST_SIZE,
PAGE_SIZE,
PARAM_DUE_DATE,
- PARAM_PAGE,
PARAM_SORT,
PARAM_STATE,
RELATIVE_POSITION_DESC,
@@ -49,7 +48,8 @@ import {
getSortOptions,
} from '~/issues_list/utils';
import axios from '~/lib/utils/axios_utils';
-import { convertObjectPropsToCamelCase, getParameterByName } from '~/lib/utils/common_utils';
+import { getParameterByName } from '~/lib/utils/common_utils';
+import { scrollUp } from '~/lib/utils/scroll_utils';
import {
DEFAULT_NONE_ANY,
OPERATOR_IS_ONLY,
@@ -107,9 +107,6 @@ export default {
emptyStateSvgPath: {
default: '',
},
- endpoint: {
- default: '',
- },
exportCsvPath: {
default: '',
},
@@ -173,15 +170,43 @@ export default {
dueDateFilter: getDueDateValue(getParameterByName(PARAM_DUE_DATE)),
exportCsvPathWithQuery: this.getExportCsvPathWithQuery(),
filterTokens: getFilterTokens(window.location.search),
- isLoading: false,
issues: [],
- page: toNumber(getParameterByName(PARAM_PAGE)) || 1,
+ pageInfo: {},
+ pageParams: initialPageParams,
showBulkEditSidebar: false,
sortKey: getSortKey(getParameterByName(PARAM_SORT)) || defaultSortKey,
state: state || IssuableStates.Opened,
totalIssues: 0,
};
},
+ apollo: {
+ issues: {
+ query: getIssuesQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ search: this.searchQuery,
+ sort: this.sortKey,
+ state: this.state,
+ ...this.pageParams,
+ ...this.apiFilterParams,
+ };
+ },
+ update: ({ project }) => project.issues.nodes,
+ result({ data }) {
+ this.pageInfo = data.project.issues.pageInfo;
+ this.totalIssues = data.project.issues.count;
+ this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
+ },
+ error(error) {
+ createFlash({ message: this.$options.i18n.errorFetchingIssues, captureError: true, error });
+ },
+ skip() {
+ return !this.hasProjectIssues;
+ },
+ debounce: 200,
+ },
+ },
computed: {
hasSearch() {
return this.searchQuery || Object.keys(this.urlFilterParams).length;
@@ -348,7 +373,6 @@ export default {
return {
due_date: this.dueDateFilter,
- page: this.page,
search: this.searchQuery,
state: this.state,
...urlSortParams[this.sortKey],
@@ -361,7 +385,6 @@ export default {
},
mounted() {
eventHub.$on('issuables:toggleBulkEdit', this.toggleBulkEditSidebar);
- this.fetchIssues();
},
beforeDestroy() {
eventHub.$off('issuables:toggleBulkEdit', this.toggleBulkEditSidebar);
@@ -406,54 +429,11 @@ export default {
fetchUsers(search) {
return axios.get(this.autocompleteUsersPath, { params: { search } });
},
- fetchIssues() {
- if (!this.hasProjectIssues) {
- return undefined;
- }
-
- this.isLoading = true;
-
- const filterParams = {
- ...this.apiFilterParams,
- };
-
- if (filterParams.epic_id) {
- filterParams.epic_id = filterParams.epic_id.split('::&').pop();
- } else if (filterParams['not[epic_id]']) {
- filterParams['not[epic_id]'] = filterParams['not[epic_id]'].split('::&').pop();
- }
-
- return axios
- .get(this.endpoint, {
- params: {
- due_date: this.dueDateFilter,
- page: this.page,
- per_page: PAGE_SIZE,
- search: this.searchQuery,
- state: this.state,
- with_labels_details: true,
- ...apiSortParams[this.sortKey],
- ...filterParams,
- },
- })
- .then(({ data, headers }) => {
- this.page = Number(headers['x-page']);
- this.totalIssues = Number(headers['x-total']);
- this.issues = data.map((issue) => convertObjectPropsToCamelCase(issue, { deep: true }));
- this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
- })
- .catch(() => {
- createFlash({ message: this.$options.i18n.errorFetchingIssues });
- })
- .finally(() => {
- this.isLoading = false;
- });
- },
getExportCsvPathWithQuery() {
return `${this.exportCsvPath}${window.location.search}`;
},
getStatus(issue) {
- if (issue.closedAt && issue.movedToId) {
+ if (issue.closedAt && issue.moved) {
return this.$options.i18n.closedMoved;
}
if (issue.closedAt) {
@@ -484,18 +464,26 @@ export default {
},
handleClickTab(state) {
if (this.state !== state) {
- this.page = 1;
+ this.pageParams = initialPageParams;
}
this.state = state;
- this.fetchIssues();
},
handleFilter(filter) {
this.filterTokens = filter;
- this.fetchIssues();
},
- handlePageChange(page) {
- this.page = page;
- this.fetchIssues();
+ handleNextPage() {
+ this.pageParams = {
+ afterCursor: this.pageInfo.endCursor,
+ firstPageSize: PAGE_SIZE,
+ };
+ scrollUp();
+ },
+ handlePreviousPage() {
+ this.pageParams = {
+ beforeCursor: this.pageInfo.startCursor,
+ lastPageSize: PAGE_SIZE,
+ };
+ scrollUp();
},
handleReorder({ newIndex, oldIndex }) {
const issueToMove = this.issues[oldIndex];
@@ -530,9 +518,11 @@ export default {
createFlash({ message: this.$options.i18n.reorderError });
});
},
- handleSort(value) {
- this.sortKey = value;
- this.fetchIssues();
+ handleSort(sortKey) {
+ if (this.sortKey !== sortKey) {
+ this.pageParams = initialPageParams;
+ }
+ this.sortKey = sortKey;
},
toggleBulkEditSidebar(showBulkEditSidebar) {
this.showBulkEditSidebar = showBulkEditSidebar;
@@ -556,18 +546,18 @@ export default {
:tabs="$options.IssuableListTabs"
:current-tab="state"
:tab-counts="tabCounts"
- :issuables-loading="isLoading"
+ :issuables-loading="$apollo.queries.issues.loading"
:is-manual-ordering="isManualOrdering"
:show-bulk-edit-sidebar="showBulkEditSidebar"
:show-pagination-controls="showPaginationControls"
- :total-items="totalIssues"
- :current-page="page"
- :previous-page="page - 1"
- :next-page="page + 1"
+ :use-keyset-pagination="true"
+ :has-next-page="pageInfo.hasNextPage"
+ :has-previous-page="pageInfo.hasPreviousPage"
:url-params="urlParams"
@click-tab="handleClickTab"
@filter="handleFilter"
- @page-change="handlePageChange"
+ @next-page="handleNextPage"
+ @previous-page="handlePreviousPage"
@reorder="handleReorder"
@sort="handleSort"
@update-legacy-bulk-edit="handleUpdateLegacyBulkEdit"
@@ -646,7 +636,7 @@ export default {
</li>
<blocking-issues-count
class="gl-display-none gl-sm-display-block"
- :blocking-issues-count="issuable.blockingIssuesCount"
+ :blocking-issues-count="issuable.blockedByCount"
:is-list-item="true"
/>
</template>
diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js
index 06e140d6420..76006f9081d 100644
--- a/app/assets/javascripts/issues_list/constants.js
+++ b/app/assets/javascripts/issues_list/constants.js
@@ -101,10 +101,13 @@ export const i18n = {
export const JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY = 'jira-import-success-alert-hide-map';
export const PARAM_DUE_DATE = 'due_date';
-export const PARAM_PAGE = 'page';
export const PARAM_SORT = 'sort';
export const PARAM_STATE = 'state';
+export const initialPageParams = {
+ firstPageSize: PAGE_SIZE,
+};
+
export const DUE_DATE_NONE = '0';
export const DUE_DATE_ANY = '';
export const DUE_DATE_OVERDUE = 'overdue';
diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js
index d0c9462a3d7..97b9a9a115d 100644
--- a/app/assets/javascripts/issues_list/index.js
+++ b/app/assets/javascripts/issues_list/index.js
@@ -73,6 +73,13 @@ export function mountIssuesListApp() {
return false;
}
+ Vue.use(VueApollo);
+
+ const defaultClient = createDefaultClient({}, { assumeImmutableResults: true });
+ const apolloProvider = new VueApollo({
+ defaultClient,
+ });
+
const {
autocompleteAwardEmojisPath,
autocompleteUsersPath,
@@ -83,7 +90,6 @@ export function mountIssuesListApp() {
email,
emailsHelpPagePath,
emptyStateSvgPath,
- endpoint,
exportCsvPath,
groupEpicsPath,
hasBlockedIssuesFeature,
@@ -113,16 +119,13 @@ export function mountIssuesListApp() {
return new Vue({
el,
- // Currently does not use Vue Apollo, but need to provide {} for now until the
- // issue is fixed upstream in https://github.com/vuejs/vue-apollo/pull/1153
- apolloProvider: {},
+ apolloProvider,
provide: {
autocompleteAwardEmojisPath,
autocompleteUsersPath,
calendarPath,
canBulkUpdate: parseBoolean(canBulkUpdate),
emptyStateSvgPath,
- endpoint,
groupEpicsPath,
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
diff --git a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues.query.graphql
new file mode 100644
index 00000000000..afd53084ca0
--- /dev/null
+++ b/app/assets/javascripts/issues_list/queries/get_issues.query.graphql
@@ -0,0 +1,45 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "./issue.fragment.graphql"
+
+query getProjectIssues(
+ $projectPath: ID!
+ $search: String
+ $sort: IssueSort
+ $state: IssuableState
+ $assigneeId: String
+ $assigneeUsernames: [String!]
+ $authorUsername: String
+ $labelName: [String]
+ $milestoneTitle: [String]
+ $not: NegatedIssueFilterInput
+ $beforeCursor: String
+ $afterCursor: String
+ $firstPageSize: Int
+ $lastPageSize: Int
+) {
+ project(fullPath: $projectPath) {
+ issues(
+ search: $search
+ sort: $sort
+ state: $state
+ assigneeId: $assigneeId
+ assigneeUsernames: $assigneeUsernames
+ authorUsername: $authorUsername
+ labelName: $labelName
+ milestoneTitle: $milestoneTitle
+ not: $not
+ before: $beforeCursor
+ after: $afterCursor
+ first: $firstPageSize
+ last: $lastPageSize
+ ) {
+ count
+ pageInfo {
+ ...PageInfo
+ }
+ nodes {
+ ...IssueFragment
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/issues_list/queries/issue.fragment.graphql b/app/assets/javascripts/issues_list/queries/issue.fragment.graphql
new file mode 100644
index 00000000000..de30d8b4bf6
--- /dev/null
+++ b/app/assets/javascripts/issues_list/queries/issue.fragment.graphql
@@ -0,0 +1,51 @@
+fragment IssueFragment on Issue {
+ id
+ iid
+ closedAt
+ confidential
+ createdAt
+ downvotes
+ dueDate
+ humanTimeEstimate
+ moved
+ title
+ updatedAt
+ upvotes
+ userDiscussionsCount
+ webUrl
+ assignees {
+ nodes {
+ id
+ avatarUrl
+ name
+ username
+ webUrl
+ }
+ }
+ author {
+ id
+ avatarUrl
+ name
+ username
+ webUrl
+ }
+ labels {
+ nodes {
+ id
+ color
+ title
+ description
+ }
+ }
+ milestone {
+ id
+ dueDate
+ startDate
+ webPath
+ title
+ }
+ taskCompletionStatus {
+ completedCount
+ count
+ }
+}
diff --git a/app/assets/javascripts/jira_connect/index.js b/app/assets/javascripts/jira_connect/index.js
index dc8bb3b0c77..bc0d21c6c9a 100644
--- a/app/assets/javascripts/jira_connect/index.js
+++ b/app/assets/javascripts/jira_connect/index.js
@@ -1,3 +1,5 @@
+import '../webpack';
+
import setConfigs from '@gitlab/ui/dist/config';
import Vue from 'vue';
import { getLocation, sizeToParent } from '~/jira_connect/utils';
diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js
index d8aab25a6a8..66e999ca43b 100644
--- a/app/assets/javascripts/performance_bar/index.js
+++ b/app/assets/javascripts/performance_bar/index.js
@@ -1,3 +1,5 @@
+import '../webpack';
+
import Vue from 'vue';
import axios from '~/lib/utils/axios_utils';
import { numberToHumanSize } from '~/lib/utils/number_utils';
diff --git a/app/assets/javascripts/runner/components/runner_manual_setup_help.vue b/app/assets/javascripts/runner/components/runner_manual_setup_help.vue
index 4755977b051..426d377c92b 100644
--- a/app/assets/javascripts/runner/components/runner_manual_setup_help.vue
+++ b/app/assets/javascripts/runner/components/runner_manual_setup_help.vue
@@ -1,8 +1,10 @@
<script>
import { GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { s__ } from '~/locale';
+import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
+import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
export default {
components: {
@@ -10,6 +12,7 @@ export default {
GlSprintf,
ClipboardButton,
RunnerInstructions,
+ RunnerRegistrationTokenReset,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -24,16 +27,40 @@ export default {
type: String,
required: true,
},
- typeName: {
+ type: {
type: String,
- required: false,
- default: __('shared'),
+ required: true,
+ validator(type) {
+ return [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE].includes(type);
+ },
},
},
+ data() {
+ return {
+ currentRegistrationToken: this.registrationToken,
+ };
+ },
computed: {
rootUrl() {
return gon.gitlab_url || '';
},
+ typeName() {
+ switch (this.type) {
+ case INSTANCE_TYPE:
+ return s__('Runners|shared');
+ case GROUP_TYPE:
+ return s__('Runners|group');
+ case PROJECT_TYPE:
+ return s__('Runners|specific');
+ default:
+ return '';
+ }
+ },
+ },
+ methods: {
+ onTokenReset(token) {
+ this.currentRegistrationToken = token;
+ },
},
};
</script>
@@ -65,12 +92,13 @@ export default {
{{ __('And this registration token:') }}
<br />
- <code data-testid="registration-token">{{ registrationToken }}</code>
- <clipboard-button :title="__('Copy token')" :text="registrationToken" />
+ <code data-testid="registration-token">{{ currentRegistrationToken }}</code>
+ <clipboard-button :title="__('Copy token')" :text="currentRegistrationToken" />
</li>
</ol>
- <!-- TODO Implement reset token functionality -->
+ <runner-registration-token-reset :type="type" @tokenReset="onTokenReset" />
+
<runner-instructions />
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_registration_token_reset.vue b/app/assets/javascripts/runner/components/runner_registration_token_reset.vue
new file mode 100644
index 00000000000..b03574264d9
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_registration_token_reset.vue
@@ -0,0 +1,83 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import createFlash, { FLASH_TYPES } from '~/flash';
+import { __, s__ } from '~/locale';
+import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql';
+import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
+
+export default {
+ components: {
+ GlButton,
+ },
+ props: {
+ type: {
+ type: String,
+ required: true,
+ validator(type) {
+ return [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE].includes(type);
+ },
+ },
+ },
+ data() {
+ return {
+ loading: false,
+ };
+ },
+ computed: {},
+ methods: {
+ async resetToken() {
+ // TODO Replace confirmation with gl-modal
+ // See: https://gitlab.com/gitlab-org/gitlab/-/issues/333810
+ // eslint-disable-next-line no-alert
+ if (!window.confirm(__('Are you sure you want to reset the registration token?'))) {
+ return;
+ }
+
+ this.loading = true;
+ try {
+ const {
+ data: {
+ runnersRegistrationTokenReset: { token, errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: runnersRegistrationTokenResetMutation,
+ variables: {
+ // TODO Currently INTANCE_TYPE only is supported
+ // In future iterations this component will support
+ // other registration token types.
+ // See: https://gitlab.com/gitlab-org/gitlab/-/issues/19819
+ input: {
+ type: this.type,
+ },
+ },
+ });
+ if (errors && errors.length) {
+ this.onError(new Error(errors[0]));
+ return;
+ }
+ this.onSuccess(token);
+ } catch (e) {
+ this.onError(e);
+ } finally {
+ this.loading = false;
+ }
+ },
+ onError(error) {
+ const { message } = error;
+ createFlash({ message });
+ },
+ onSuccess(token) {
+ createFlash({
+ message: s__('Runners|New registration token generated!'),
+ type: FLASH_TYPES.SUCCESS,
+ });
+ this.$emit('tokenReset', token);
+ },
+ },
+};
+</script>
+<template>
+ <gl-button :loading="loading" @click="resetToken">
+ {{ __('Reset registration token') }}
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/runner/graphql/runners_registration_token_reset.mutation.graphql b/app/assets/javascripts/runner/graphql/runners_registration_token_reset.mutation.graphql
new file mode 100644
index 00000000000..9c2797732ad
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/runners_registration_token_reset.mutation.graphql
@@ -0,0 +1,6 @@
+mutation runnersRegistrationTokenReset($input: RunnersRegistrationTokenResetInput!) {
+ runnersRegistrationTokenReset(input: $input) {
+ token
+ errors
+ }
+}
diff --git a/app/assets/javascripts/runner/runner_list/runner_list_app.vue b/app/assets/javascripts/runner/runner_list/runner_list_app.vue
index b4eacb911a2..7f3a980ccca 100644
--- a/app/assets/javascripts/runner/runner_list/runner_list_app.vue
+++ b/app/assets/javascripts/runner/runner_list/runner_list_app.vue
@@ -7,6 +7,7 @@ import RunnerList from '../components/runner_list.vue';
import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeHelp from '../components/runner_type_help.vue';
+import { INSTANCE_TYPE } from '../constants';
import getRunnersQuery from '../graphql/get_runners.query.graphql';
import {
fromUrlQueryToSearch,
@@ -97,6 +98,7 @@ export default {
});
},
},
+ INSTANCE_TYPE,
};
</script>
<template>
@@ -106,7 +108,10 @@ export default {
<runner-type-help />
</div>
<div class="col-sm-6">
- <runner-manual-setup-help :registration-token="registrationToken" />
+ <runner-manual-setup-help
+ :registration-token="registrationToken"
+ :type="$options.INSTANCE_TYPE"
+ />
</div>
</div>
diff --git a/app/assets/javascripts/sentry/index.js b/app/assets/javascripts/sentry/index.js
index 06e4e0aa507..a875ef84088 100644
--- a/app/assets/javascripts/sentry/index.js
+++ b/app/assets/javascripts/sentry/index.js
@@ -1,3 +1,5 @@
+import '../webpack';
+
import SentryConfig from './sentry_config';
const index = function index() {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue
index b25c0cc0d96..bdd46d6a656 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue
@@ -71,7 +71,7 @@ export default {
:aria-label="buttonTitle"
:loading="isLoading"
:disabled="isActionInProgress"
- :class="`inline gl-ml-2 ${containerClasses}`"
+ :class="`inline gl-ml-3 ${containerClasses}`"
:icon="icon"
@click="$emit('click')"
>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
index 671f9cb8e74..7e587663c26 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
@@ -151,7 +151,7 @@ export default {
</script>
<template>
- <div>
+ <div class="gl-display-inline-flex">
<deployment-action-button
v-if="canBeManuallyDeployed"
:action-in-progress="actionInProgress"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
index a5d165ebd49..459bee8023f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
@@ -71,13 +71,13 @@ export default {
};
</script>
<template>
- <span>
+ <span class="gl-display-inline-flex">
<gl-button-group v-if="shouldRenderDropdown" size="small">
<review-app-link
:display="appButtonText"
:link="deploymentExternalUrl"
size="small"
- css-class="deploy-link js-deploy-url inline"
+ css-class="deploy-link js-deploy-url inline gl-ml-3"
/>
<gl-dropdown toggle-class="gl-px-2!" size="small" class="js-mr-wigdet-deployment-dropdown">
<template #button-content>
@@ -112,7 +112,7 @@ export default {
:display="appButtonText"
:link="deploymentExternalUrl"
size="small"
- css-class="js-deploy-url deploy-link btn btn-default btn-sm inline"
+ css-class="js-deploy-url deploy-link btn btn-default btn-sm inline gl-ml-3"
/>
<visual-review-app-link
v-if="showVisualReviewApp"
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
index 2e7b3e149b2..3b261f5ac25 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
@@ -71,9 +71,9 @@ export default {
<template>
<base-token
- :token-config="config"
- :token-value="value"
- :token-active="active"
+ :config="config"
+ :value="value"
+ :active="active"
:tokens-list-loading="loading"
:token-values="authors"
:fn-active-token-value="getActiveAuthor"
@@ -81,6 +81,7 @@ export default {
:preloaded-token-values="preloadedAuthors"
:recent-token-values-storage-key="config.recentTokenValuesStorageKey"
@fetch-token-values="fetchAuthorBySearchTerm"
+ v-on="$listeners"
>
<template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
<gl-avatar
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
index fb6b9e4bc0d..bda6b340871 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
@@ -19,29 +19,34 @@ export default {
GlLoadingIcon,
},
props: {
- tokenConfig: {
+ config: {
type: Object,
required: true,
},
- tokenValue: {
+ value: {
type: Object,
required: true,
},
- tokenActive: {
+ active: {
type: Boolean,
required: true,
},
tokensListLoading: {
type: Boolean,
- required: true,
+ required: false,
+ default: false,
},
tokenValues: {
type: Array,
- required: true,
+ required: false,
+ default: () => [],
},
fnActiveTokenValue: {
type: Function,
- required: true,
+ required: false,
+ default: (tokenValues, currentTokenValue) => {
+ return tokenValues.find(({ value }) => value === currentTokenValue);
+ },
},
defaultTokenValues: {
type: Array,
@@ -90,9 +95,9 @@ export default {
},
currentTokenValue() {
if (this.fnCurrentTokenValue) {
- return this.fnCurrentTokenValue(this.tokenValue.data);
+ return this.fnCurrentTokenValue(this.value.data);
}
- return this.tokenValue.data.toLowerCase();
+ return this.value.data.toLowerCase();
},
activeTokenValue() {
return this.fnActiveTokenValue(this.tokenValues, this.currentTokenValue);
@@ -113,11 +118,11 @@ export default {
},
},
watch: {
- tokenActive: {
+ active: {
immediate: true,
handler(newValue) {
if (!newValue && !this.tokenValues.length) {
- this.$emit('fetch-token-values', this.tokenValue.data);
+ this.$emit('fetch-token-values', this.value.data);
}
},
},
@@ -148,9 +153,11 @@ export default {
<template>
<gl-filtered-search-token
- :config="tokenConfig"
- v-bind="{ ...this.$parent.$props, ...this.$parent.$attrs }"
- v-on="this.$parent.$listeners"
+ :config="config"
+ :value="value"
+ :active="active"
+ v-bind="$attrs"
+ v-on="$listeners"
@input="handleInput"
@select="handleTokenValueSelected(activeTokenValue)"
>
@@ -177,7 +184,7 @@ export default {
<gl-dropdown-divider />
</template>
<slot
- v-if="preloadedTokenValues.length"
+ v-if="preloadedTokenValues.length && !searchKey"
name="token-values-list"
:token-values="preloadedTokenValues"
></slot>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
index 20b8cbfe933..e496d099a42 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
@@ -93,15 +93,16 @@ export default {
<template>
<base-token
- :token-config="config"
- :token-value="value"
- :token-active="active"
+ :config="config"
+ :value="value"
+ :active="active"
:tokens-list-loading="loading"
:token-values="labels"
:fn-active-token-value="getActiveLabel"
:default-token-values="defaultLabels"
:recent-token-values-storage-key="config.recentTokenValuesStorageKey"
@fetch-token-values="fetchLabelBySearchTerm"
+ v-on="$listeners"
>
<template
#view-token="{ viewTokenProps: { inputValue, cssClasses, listeners, activeTokenValue } }"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
index d80b66fd9be..1f0704f7308 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
@@ -1,5 +1,6 @@
<script>
-import { mapGetters, mapState } from 'vuex';
+import { GlButton } from '@gitlab/ui';
+import { mapActions, mapGetters, mapState } from 'vuex';
import DropdownContentsCreateView from './dropdown_contents_create_view.vue';
import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue';
@@ -8,6 +9,7 @@ export default {
components: {
DropdownContentsLabelsView,
DropdownContentsCreateView,
+ GlButton,
},
props: {
renderOnTop: {
@@ -15,10 +17,14 @@ export default {
required: false,
default: false,
},
+ labelsCreateTitle: {
+ type: String,
+ required: true,
+ },
},
computed: {
- ...mapState(['showDropdownContentsCreateView']),
- ...mapGetters(['isDropdownVariantSidebar']),
+ ...mapState(['showDropdownContentsCreateView', 'labelsListTitle']),
+ ...mapGetters(['isDropdownVariantSidebar', 'isDropdownVariantEmbedded']),
dropdownContentsView() {
if (this.showDropdownContentsCreateView) {
return 'dropdown-contents-create-view';
@@ -29,6 +35,12 @@ export default {
const bottom = this.isDropdownVariantSidebar ? '3rem' : '2rem';
return this.renderOnTop ? { bottom } : {};
},
+ dropdownTitle() {
+ return this.showDropdownContentsCreateView ? this.labelsCreateTitle : this.labelsListTitle;
+ },
+ },
+ methods: {
+ ...mapActions(['toggleDropdownContentsCreateView', 'toggleDropdownContents']),
},
};
</script>
@@ -39,6 +51,30 @@ export default {
data-qa-selector="labels_dropdown_content"
:style="directionStyle"
>
- <component :is="dropdownContentsView" />
+ <div
+ v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
+ class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
+ data-testid="dropdown-title"
+ >
+ <gl-button
+ v-if="showDropdownContentsCreateView"
+ :aria-label="__('Go back')"
+ variant="link"
+ size="small"
+ class="js-btn-back dropdown-header-button p-0"
+ icon="arrow-left"
+ @click="toggleDropdownContentsCreateView"
+ />
+ <span class="flex-grow-1">{{ dropdownTitle }}</span>
+ <gl-button
+ :aria-label="__('Close')"
+ variant="link"
+ size="small"
+ class="dropdown-header-button gl-p-0!"
+ icon="close"
+ @click="toggleDropdownContents"
+ />
+ </div>
+ <component :is="dropdownContentsView" @hideCreateView="toggleDropdownContentsCreateView" />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
index f8cc981ba3d..a7f20fbe851 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
@@ -1,6 +1,10 @@
<script>
import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
-import { mapState, mapActions } from 'vuex';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+import createLabelMutation from './graphql/create_label.mutation.graphql';
+
+const errorMessage = __('Error creating label.');
export default {
components: {
@@ -12,14 +16,19 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ inject: {
+ projectPath: {
+ default: '',
+ },
+ },
data() {
return {
labelTitle: '',
selectedColor: '',
+ labelCreateInProgress: false,
};
},
computed: {
- ...mapState(['labelsCreateTitle', 'labelCreateInProgress']),
disableCreate() {
return !this.labelTitle.length || !this.selectedColor.length || this.labelCreateInProgress;
},
@@ -29,7 +38,6 @@ export default {
},
},
methods: {
- ...mapActions(['toggleDropdownContents', 'toggleDropdownContentsCreateView', 'createLabel']),
getColorCode(color) {
return Object.keys(color).pop();
},
@@ -39,11 +47,27 @@ export default {
handleColorClick(color) {
this.selectedColor = this.getColorCode(color);
},
- handleCreateClick() {
- this.createLabel({
- title: this.labelTitle,
- color: this.selectedColor,
- });
+ async createLabel() {
+ this.labelCreateInProgress = true;
+ try {
+ const {
+ data: { labelCreate },
+ } = await this.$apollo.mutate({
+ mutation: createLabelMutation,
+ variables: {
+ title: this.labelTitle,
+ color: this.selectedColor,
+ projectPath: this.projectPath,
+ },
+ });
+ if (labelCreate.errors.length) {
+ createFlash({ message: errorMessage });
+ }
+ } catch {
+ createFlash({ message: errorMessage });
+ }
+ this.labelCreateInProgress = false;
+ this.$emit('hideCreateView');
},
},
};
@@ -51,34 +75,16 @@ export default {
<template>
<div class="labels-select-contents-create js-labels-create">
- <div class="dropdown-title d-flex align-items-center pt-0 pb-2">
- <gl-button
- :aria-label="__('Go back')"
- variant="link"
- size="small"
- class="js-btn-back dropdown-header-button p-0"
- icon="arrow-left"
- @click="toggleDropdownContentsCreateView"
- />
- <span class="flex-grow-1">{{ labelsCreateTitle }}</span>
- <gl-button
- :aria-label="__('Close')"
- variant="link"
- size="small"
- class="dropdown-header-button p-0"
- icon="close"
- @click="toggleDropdownContents"
- />
- </div>
<div class="dropdown-input">
<gl-form-input
v-model.trim="labelTitle"
:placeholder="__('Name new label')"
:autofocus="true"
+ data-testid="label-title-input"
/>
</div>
- <div class="dropdown-content px-2">
- <div class="suggest-colors suggest-colors-dropdown mt-0 mb-2">
+ <div class="dropdown-content gl-px-3">
+ <div class="suggest-colors suggest-colors-dropdown gl-mt-0! gl-mb-3!">
<gl-link
v-for="(color, index) in suggestedColors"
:key="index"
@@ -90,28 +96,35 @@ export default {
</div>
<div class="color-input-container gl-display-flex">
<span
- class="dropdown-label-color-preview position-relative position-relative d-inline-block"
+ class="dropdown-label-color-preview gl-relative gl-display-inline-block"
+ data-testid="selected-color"
:style="{ backgroundColor: selectedColor }"
></span>
<gl-form-input
v-model.trim="selectedColor"
class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
:placeholder="__('Use custom color #FF0000')"
+ data-testid="selected-color-text"
/>
</div>
</div>
- <div class="dropdown-actions clearfix pt-2 px-2">
+ <div class="dropdown-actions gl-display-flex gl-justify-content-space-between gl-pt-3 gl-px-3">
<gl-button
:disabled="disableCreate"
category="primary"
variant="success"
- class="float-left d-flex align-items-center"
- @click="handleCreateClick"
+ class="gl-display-flex gl-align-items-center"
+ data-testid="create-button"
+ @click="createLabel"
>
- <gl-loading-icon v-show="labelCreateInProgress" :inline="true" class="mr-1" />
+ <gl-loading-icon v-if="labelCreateInProgress" :inline="true" class="mr-1" />
{{ __('Create') }}
</gl-button>
- <gl-button class="float-right js-btn-cancel-create" @click="toggleDropdownContentsCreateView">
+ <gl-button
+ class="js-btn-cancel-create"
+ data-testid="cancel-button"
+ @click="$emit('hideCreateView')"
+ >
{{ __('Cancel') }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
index 86788a84260..bff34743344 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
@@ -1,11 +1,5 @@
<script>
-import {
- GlIntersectionObserver,
- GlLoadingIcon,
- GlButton,
- GlSearchBoxByType,
- GlLink,
-} from '@gitlab/ui';
+import { GlIntersectionObserver, GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { mapState, mapGetters, mapActions } from 'vuex';
@@ -17,7 +11,6 @@ export default {
components: {
GlIntersectionObserver,
GlLoadingIcon,
- GlButton,
GlSearchBoxByType,
GlLink,
LabelItem,
@@ -149,21 +142,6 @@ export default {
<template>
<gl-intersection-observer @appear="handleComponentAppear" @disappear="handleComponentDisappear">
<div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown">
- <div
- v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
- class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
- data-testid="dropdown-title"
- >
- <span class="flex-grow-1">{{ labelsListTitle }}</span>
- <gl-button
- :aria-label="__('Close')"
- variant="link"
- size="small"
- class="dropdown-header-button gl-p-0!"
- icon="close"
- @click="toggleDropdownContents"
- />
- </div>
<div class="dropdown-input" @click.stop="() => {}">
<gl-search-box-by-type
ref="searchInput"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue
new file mode 100644
index 00000000000..122250d1ce7
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue
@@ -0,0 +1,55 @@
+<script>
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+
+export default {
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ GlIcon,
+ },
+ props: {
+ labels: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ labelsList() {
+ const labelsString = this.labels.length
+ ? this.labels
+ .slice(0, 5)
+ .map((label) => label.title)
+ .join(', ')
+ : s__('LabelSelect|Labels');
+
+ if (this.labels.length > 5) {
+ return sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), {
+ labelsString,
+ remainingLabelCount: this.labels.length - 5,
+ });
+ }
+
+ return labelsString;
+ },
+ },
+ methods: {
+ handleClick() {
+ this.$emit('onValueClick');
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ v-gl-tooltip.left.viewport
+ :title="labelsList"
+ class="sidebar-collapsed-icon"
+ @click="handleClick"
+ >
+ <gl-icon name="labels" />
+ <span>{{ labels.length }}</span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql
new file mode 100644
index 00000000000..9aa4f5d165e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql
@@ -0,0 +1,15 @@
+mutation createLabel($title: String!, $color: String, $projectPath: ID, $groupPath: ID) {
+ labelCreate(
+ input: { title: $title, color: $color, projectPath: $projectPath, groupPath: $groupPath }
+ ) {
+ label {
+ id
+ color
+ description
+ descriptionHtml
+ title
+ textColor
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
index bf30e3cfac5..7728c758e18 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
@@ -5,13 +5,12 @@ import Vuex, { mapState, mapActions, mapGetters } from 'vuex';
import { isInViewport } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
-import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue';
-
import { DropdownVariant } from './constants';
import DropdownButton from './dropdown_button.vue';
import DropdownContents from './dropdown_contents.vue';
import DropdownTitle from './dropdown_title.vue';
import DropdownValue from './dropdown_value.vue';
+import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
import labelsSelectModule from './store';
Vue.use(Vuex);
@@ -163,7 +162,6 @@ export default {
labelsFilterBasePath: this.labelsFilterBasePath,
labelsFilterParam: this.labelsFilterParam,
labelsListTitle: this.labelsListTitle,
- labelsCreateTitle: this.labelsCreateTitle,
footerCreateLabelTitle: this.footerCreateLabelTitle,
footerManageLabelTitle: this.footerManageLabelTitle,
});
@@ -313,6 +311,7 @@ export default {
v-show="dropdownButtonVisible && showDropdownContents"
ref="dropdownContents"
:render-on-top="!contentIsOnViewport"
+ :labels-create-title="labelsCreateTitle"
/>
</template>
<template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded">
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js
index 89f96ab916b..2b96b159ca3 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js
@@ -28,31 +28,5 @@ export const fetchLabels = ({ state, dispatch }) => {
.catch(() => dispatch('receiveLabelsFailure'));
};
-export const requestCreateLabel = ({ commit }) => commit(types.REQUEST_CREATE_LABEL);
-export const receiveCreateLabelSuccess = ({ commit }) => commit(types.RECEIVE_CREATE_LABEL_SUCCESS);
-export const receiveCreateLabelFailure = ({ commit }) => {
- commit(types.RECEIVE_CREATE_LABEL_FAILURE);
- flash(__('Error creating label.'));
-};
-export const createLabel = ({ state, dispatch }, label) => {
- dispatch('requestCreateLabel');
- axios
- .post(state.labelsManagePath, {
- label,
- })
- .then(({ data }) => {
- if (data.id) {
- dispatch('receiveCreateLabelSuccess');
- dispatch('toggleDropdownContentsCreateView');
- } else {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- throw new Error('Error Creating Label');
- }
- })
- .catch(() => {
- dispatch('receiveCreateLabelFailure');
- });
-};
-
export const updateSelectedLabels = ({ commit }, labels) =>
commit(types.UPDATE_SELECTED_LABELS, { labels });
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js
index 2e044dc3b3c..b8da7a90b36 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js
@@ -8,10 +8,6 @@ export const REQUEST_SET_LABELS = 'REQUEST_SET_LABELS';
export const RECEIVE_SET_LABELS_SUCCESS = 'RECEIVE_SET_LABELS_SUCCESS';
export const RECEIVE_SET_LABELS_FAILURE = 'RECEIVE_SET_LABELS_FAILURE';
-export const REQUEST_CREATE_LABEL = 'REQUEST_CREATE_LABEL';
-export const RECEIVE_CREATE_LABEL_SUCCESS = 'RECEIVE_CREATE_LABEL_SUCCESS';
-export const RECEIVE_CREATE_LABEL_FAILURE = 'RECEIVE_CREATE_LABEL_FAILURE';
-
export const TOGGLE_DROPDOWN_BUTTON = 'TOGGLE_DROPDOWN_VISIBILITY';
export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS';
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js
index 55716e1105e..131c6e6fb57 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js
@@ -46,17 +46,6 @@ export default {
[types.RECEIVE_SET_LABELS_FAILURE](state) {
state.labelsFetchInProgress = false;
},
-
- [types.REQUEST_CREATE_LABEL](state) {
- state.labelCreateInProgress = true;
- },
- [types.RECEIVE_CREATE_LABEL_SUCCESS](state) {
- state.labelCreateInProgress = false;
- },
- [types.RECEIVE_CREATE_LABEL_FAILURE](state) {
- state.labelCreateInProgress = false;
- },
-
[types.UPDATE_SELECTED_LABELS](state, { labels }) {
// Find the label to update from all the labels
// and change `set` prop value to represent their current state.
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js
index d66cfed4163..220bab05ed2 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js
@@ -3,7 +3,6 @@ export default () => ({
labels: [],
selectedLabels: [],
labelsListTitle: '',
- labelsCreateTitle: '',
footerCreateLabelTitle: '',
footerManageLabelTitle: '',
dropdownButtonText: '',
diff --git a/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue
index 9e941087da2..5d39d740c07 100644
--- a/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue
@@ -35,7 +35,7 @@ export default {
<template>
<gl-dropdown
v-gl-tooltip
- :title="s__('SecurityReports|Download results')"
+ :text="s__('SecurityReports|Download results')"
:loading="loading"
icon="download"
size="small"
diff --git a/app/assets/javascripts/webpack.js b/app/assets/javascripts/webpack.js
index 4f558843357..b901f17790f 100644
--- a/app/assets/javascripts/webpack.js
+++ b/app/assets/javascripts/webpack.js
@@ -2,6 +2,9 @@
* This is the first script loaded by webpack's runtime. It is used to manually configure
* config.output.publicPath to account for relative_url_root or CDN settings which cannot be
* baked-in to our webpack bundles.
+ *
+ * Note: This file should be at the top of an entry point and _cannot_ be moved to
+ * e.g. the `window` scope, because it needs to be executed in the scope of webpack.
*/
if (gon && gon.webpack_public_path) {
diff --git a/app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar_variant.scss b/app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar_variant.scss
index 154b8c31e8b..1ea50281204 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar_variant.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar_variant.scss
@@ -13,13 +13,49 @@ $top-level-item-color: $purple-900;
box-shadow: none;
}
+&.gl-dark .nav-sidebar .sidebar-sub-level-items {
+ box-shadow: none;
+ border: 1px solid $border-color;
+}
+
+&.gl-dark .sidebar-top-level-items .context-header a .avatar-container.rect-avatar .avatar.s32 {
+ color: $white;
+}
+
&.gl-dark .nav-sidebar li a,
&.gl-dark .toggle-sidebar-button .collapse-text,
&.gl-dark .toggle-sidebar-button .icon-chevron-double-lg-left,
&.gl-dark .toggle-sidebar-button .icon-chevron-double-lg-right,
&.gl-dark .sidebar-top-level-items .context-header a .sidebar-context-title,
-&.gl-dark .nav-sidebar-inner-scroll > div.context-header a .sidebar-context-title {
+&.gl-dark .nav-sidebar-inner-scroll > div.context-header a .sidebar-context-title,
+&.gl-dark .nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item a,
+&.gl-dark .nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item a:hover,
+&.gl-dark .nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item.active a,
+&.gl-dark .nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item .fly-out-top-item-container {
+ color: $gray-darkest;
+}
+
+&.gl-dark .nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item a,
+&.gl-dark .nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item a:hover,
+&.gl-dark .nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item.active a,
+&.gl-dark .nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item .fly-out-top-item-container {
+ @include gl-mt-0;
+}
+
+&.gl-dark .nav-sidebar a:not(.has-sub-items) + .sidebar-sub-level-items .fly-out-top-item a,
+&.gl-dark .nav-sidebar a:not(.has-sub-items) + .sidebar-sub-level-items .fly-out-top-item a:hover,
+&.gl-dark .nav-sidebar a:not(.has-sub-items) + .sidebar-sub-level-items .fly-out-top-item.active a,
+&.gl-dark .nav-sidebar a:not(.has-sub-items) + .sidebar-sub-level-items .fly-out-top-item .fly-out-top-item-container {
+ background: $white;
color: $gray-darkest;
+
+ &::before {
+ border-right-color: $white;
+ }
+}
+
+&.gl-dark .nav-sidebar .sidebar-sub-level-items {
+ background-color: $white;
}
&.ui-indigo .nav-sidebar li.active:not(.fly-out-top-item) > a {
@@ -183,7 +219,7 @@ $top-level-item-color: $purple-900;
.avatar.s32 {
@extend .rect-avatar.s32;
- color: $gray-900;
+ //color: $gray-900;
box-shadow: $avatar-box-shadow;
}
}
@@ -226,7 +262,7 @@ $top-level-item-color: $purple-900;
color: $white;
@if $has-sub-items {
- @include gl-mt-n2;
+ @include gl-mt-0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
} @else {
@@ -244,13 +280,13 @@ $top-level-item-color: $purple-900;
content: '';
display: block;
top: 50%;
- left: $gl-spacing-scale-3/-2;
- margin-top: -$gl-spacing-scale-3;
+ left: -$gl-spacing-scale-2;
+ margin-top: -$gl-spacing-scale-2;
width: 0;
height: 0;
- border-top: $gl-spacing-scale-3 solid transparent;
- border-bottom: $gl-spacing-scale-3 solid transparent;
- border-right: $gl-spacing-scale-3 solid $black;
+ border-top: $gl-spacing-scale-2 solid transparent;
+ border-bottom: $gl-spacing-scale-2 solid transparent;
+ border-right: $gl-spacing-scale-2 solid $black;
}
}
}
@@ -356,6 +392,8 @@ $top-level-item-color: $purple-900;
}
a.has-sub-items + .sidebar-sub-level-items {
+ @include gl-mt-n2;
+
.fly-out-top-item {
@include fly-out-top-item($has-sub-items: true);
}
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index c6f0b3a2ba7..00a6ee579d8 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -1240,6 +1240,18 @@ input {
body.sidebar-refactoring.gl-dark .nav-sidebar li.active {
box-shadow: none;
}
+body.sidebar-refactoring.gl-dark .nav-sidebar .sidebar-sub-level-items {
+ box-shadow: none;
+ border: 1px solid #404040;
+}
+body.sidebar-refactoring.gl-dark
+ .sidebar-top-level-items
+ .context-header
+ a
+ .avatar-container.rect-avatar
+ .avatar.s32 {
+ color: #333;
+}
body.sidebar-refactoring.gl-dark .nav-sidebar li a,
body.sidebar-refactoring.gl-dark .toggle-sidebar-button .collapse-text,
body.sidebar-refactoring.gl-dark
@@ -1257,9 +1269,91 @@ body.sidebar-refactoring.gl-dark
.nav-sidebar-inner-scroll
> div.context-header
a
- .sidebar-context-title {
+ .sidebar-context-title,
+body.sidebar-refactoring.gl-dark
+ .nav-sidebar
+ a.has-sub-items
+ + .sidebar-sub-level-items
+ .fly-out-top-item
+ a,
+body.sidebar-refactoring.gl-dark
+ .nav-sidebar
+ a.has-sub-items
+ + .sidebar-sub-level-items
+ .fly-out-top-item.active
+ a,
+body.sidebar-refactoring.gl-dark
+ .nav-sidebar
+ a.has-sub-items
+ + .sidebar-sub-level-items
+ .fly-out-top-item
+ .fly-out-top-item-container {
+ color: #c4c4c4;
+}
+body.sidebar-refactoring.gl-dark
+ .nav-sidebar
+ a.has-sub-items
+ + .sidebar-sub-level-items
+ .fly-out-top-item
+ a,
+body.sidebar-refactoring.gl-dark
+ .nav-sidebar
+ a.has-sub-items
+ + .sidebar-sub-level-items
+ .fly-out-top-item.active
+ a,
+body.sidebar-refactoring.gl-dark
+ .nav-sidebar
+ a.has-sub-items
+ + .sidebar-sub-level-items
+ .fly-out-top-item
+ .fly-out-top-item-container {
+ margin-top: 0;
+}
+body.sidebar-refactoring.gl-dark
+ .nav-sidebar
+ a:not(.has-sub-items)
+ + .sidebar-sub-level-items
+ .fly-out-top-item
+ a,
+body.sidebar-refactoring.gl-dark
+ .nav-sidebar
+ a:not(.has-sub-items)
+ + .sidebar-sub-level-items
+ .fly-out-top-item.active
+ a,
+body.sidebar-refactoring.gl-dark
+ .nav-sidebar
+ a:not(.has-sub-items)
+ + .sidebar-sub-level-items
+ .fly-out-top-item
+ .fly-out-top-item-container {
+ background: #333;
color: #c4c4c4;
}
+body.sidebar-refactoring.gl-dark
+ .nav-sidebar
+ a:not(.has-sub-items)
+ + .sidebar-sub-level-items
+ .fly-out-top-item
+ a::before,
+body.sidebar-refactoring.gl-dark
+ .nav-sidebar
+ a:not(.has-sub-items)
+ + .sidebar-sub-level-items
+ .fly-out-top-item.active
+ a::before,
+body.sidebar-refactoring.gl-dark
+ .nav-sidebar
+ a:not(.has-sub-items)
+ + .sidebar-sub-level-items
+ .fly-out-top-item
+ .fly-out-top-item-container::before {
+ border-right-color: #333;
+}
+body.sidebar-refactoring.gl-dark .nav-sidebar .sidebar-sub-level-items {
+ background-color: #333;
+}
body.sidebar-refactoring.ui-indigo
.nav-sidebar
li.active:not(.fly-out-top-item)
@@ -1482,12 +1576,18 @@ body.sidebar-refactoring
display: block;
top: 50%;
left: -0.25rem;
- margin-top: -0.5rem;
+ margin-top: -0.25rem;
width: 0;
height: 0;
- border-top: 0.5rem solid transparent;
- border-bottom: 0.5rem solid transparent;
- border-right: 0.5rem solid #fff;
+ border-top: 0.25rem solid transparent;
+ border-bottom: 0.25rem solid transparent;
+ border-right: 0.25rem solid #fff;
+}
+body.sidebar-refactoring
+ .nav-sidebar
+ a.has-sub-items
+ + .sidebar-sub-level-items {
+ margin-top: -0.25rem;
}
body.sidebar-refactoring
.nav-sidebar
@@ -1523,7 +1623,7 @@ body.sidebar-refactoring
font-size: 0.75rem;
background-color: #2f2a6b;
color: #333;
- margin-top: -0.25rem;
+ margin-top: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
@@ -1691,7 +1791,6 @@ body.sidebar-refactoring
a
.avatar-container.rect-avatar
.avatar.s32 {
- color: #fafafa;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
body.sidebar-refactoring
@@ -1732,7 +1831,6 @@ body.sidebar-refactoring
a
.avatar-container.rect-avatar
.avatar.s32 {
- color: #fafafa;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
body.sidebar-refactoring
diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss
index a05e27b6af0..4605b6de563 100644
--- a/app/assets/stylesheets/startup/startup-general.scss
+++ b/app/assets/stylesheets/startup/startup-general.scss
@@ -1444,12 +1444,18 @@ body.sidebar-refactoring
display: block;
top: 50%;
left: -0.25rem;
- margin-top: -0.5rem;
+ margin-top: -0.25rem;
width: 0;
height: 0;
- border-top: 0.5rem solid transparent;
- border-bottom: 0.5rem solid transparent;
- border-right: 0.5rem solid #000;
+ border-top: 0.25rem solid transparent;
+ border-bottom: 0.25rem solid transparent;
+ border-right: 0.25rem solid #000;
+}
+body.sidebar-refactoring
+ .nav-sidebar
+ a.has-sub-items
+ + .sidebar-sub-level-items {
+ margin-top: -0.25rem;
}
body.sidebar-refactoring
.nav-sidebar
@@ -1485,7 +1491,7 @@ body.sidebar-refactoring
font-size: 0.75rem;
background-color: #2f2a6b;
color: #fff;
- margin-top: -0.25rem;
+ margin-top: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
@@ -1653,7 +1659,6 @@ body.sidebar-refactoring
a
.avatar-container.rect-avatar
.avatar.s32 {
- color: #303030;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
body.sidebar-refactoring
@@ -1694,7 +1699,6 @@ body.sidebar-refactoring
a
.avatar-container.rect-avatar
.avatar.s32 {
- color: #303030;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
body.sidebar-refactoring
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 5ddeb9630ba..7960e5d64d0 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -208,7 +208,10 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
params[:application_setting][:import_sources]&.delete("")
params[:application_setting][:restricted_visibility_levels]&.delete("")
- params[:application_setting][:required_instance_ci_template] = nil if params[:application_setting][:required_instance_ci_template].blank?
+
+ if params[:application_setting].key?(:required_instance_ci_template)
+ params[:application_setting][:required_instance_ci_template] = nil if params[:application_setting][:required_instance_ci_template].empty?
+ end
remove_blank_params_for!(:elasticsearch_aws_secret_access_key, :eks_secret_access_key)
@@ -217,9 +220,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
params.delete(:domain_denylist_raw) if params[:domain_denylist]
params.delete(:domain_allowlist_raw) if params[:domain_allowlist]
- params.require(:application_setting).permit(
- visible_application_setting_attributes
- )
+ params[:application_setting].permit(visible_application_setting_attributes)
end
def recheck_user_consent?
diff --git a/app/controllers/admin/cohorts_controller.rb b/app/controllers/admin/cohorts_controller.rb
index c29b5224b09..8163f062b62 100644
--- a/app/controllers/admin/cohorts_controller.rb
+++ b/app/controllers/admin/cohorts_controller.rb
@@ -1,11 +1,28 @@
# frozen_string_literal: true
class Admin::CohortsController < Admin::ApplicationController
+ include Analytics::UniqueVisitsHelper
+
feature_category :devops_reports
- # Backwards compatibility. Remove it and routing in 14.0
- # @see https://gitlab.com/gitlab-org/gitlab/-/issues/299303
def index
- redirect_to cohorts_admin_users_path
+ @cohorts = load_cohorts
+ track_cohorts_visit
+ end
+
+ private
+
+ def load_cohorts
+ cohorts_results = Rails.cache.fetch('cohorts', expires_in: 1.day) do
+ CohortsService.new.execute
+ end
+
+ CohortsSerializer.new.represent(cohorts_results)
+ end
+
+ def track_cohorts_visit
+ if request.format.html? && request.headers['DNT'] != '1'
+ track_visit('i_analytics_cohorts')
+ end
end
end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index e397ecbadaf..700acc46d8d 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -2,9 +2,8 @@
class Admin::UsersController < Admin::ApplicationController
include RoutableActions
- include Analytics::UniqueVisitsHelper
- before_action :user, except: [:index, :cohorts, :new, :create]
+ before_action :user, except: [:index, :new, :create]
before_action :check_impersonation_availability, only: :impersonate
before_action :ensure_destroy_prerequisites_met, only: [:destroy]
before_action :check_ban_user_feature_flag, only: [:ban]
@@ -14,7 +13,7 @@ class Admin::UsersController < Admin::ApplicationController
PAGINATION_WITH_COUNT_LIMIT = 1000
def index
- return redirect_to cohorts_admin_users_path if params[:tab] == 'cohorts'
+ return redirect_to admin_cohorts_path if params[:tab] == 'cohorts'
@users = User.filter_items(params[:filter]).order_name_asc
@users = @users.search_with_secondary_emails(params[:search_query]) if params[:search_query].present?
@@ -24,11 +23,6 @@ class Admin::UsersController < Admin::ApplicationController
@users = @users.without_count if paginate_without_count?
end
- def cohorts
- @cohorts = load_cohorts
- track_cohorts_visit
- end
-
def show
end
@@ -376,20 +370,6 @@ class Admin::UsersController < Admin::ApplicationController
def log_impersonation_event
Gitlab::AppLogger.info(_("User %{current_user_username} has started impersonating %{username}") % { current_user_username: current_user.username, username: user.username })
end
-
- def load_cohorts
- cohorts_results = Rails.cache.fetch('cohorts', expires_in: 1.day) do
- CohortsService.new.execute
- end
-
- CohortsSerializer.new.represent(cohorts_results)
- end
-
- def track_cohorts_visit
- if request.format.html? && request.headers['DNT'] != '1'
- track_visit('i_analytics_cohorts')
- end
- end
end
Admin::UsersController.prepend_mod_with('Admin::UsersController')
diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb
index 560369a8de4..0b833e149a4 100644
--- a/app/controllers/confirmations_controller.rb
+++ b/app/controllers/confirmations_controller.rb
@@ -13,7 +13,7 @@ class ConfirmationsController < Devise::ConfirmationsController
protected
def after_resending_confirmation_instructions_path_for(resource)
- return users_almost_there_path(email: resource.email) unless Feature.enabled?(:soft_email_confirmation)
+ return users_almost_there_path unless Feature.enabled?(:soft_email_confirmation)
stored_location_for(resource) || dashboard_projects_path
end
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index 76de9a83c87..8519841ee16 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -15,9 +15,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
push_frontend_feature_flag(:prometheus_computed_alerts)
push_frontend_feature_flag(:disable_metric_dashboard_refresh_rate)
end
- before_action do
- push_frontend_feature_flag(:canary_ingress_weight_control, default_enabled: true)
- end
+
before_action :authorize_read_environment!, except: [:metrics, :additional_metrics, :metrics_dashboard, :metrics_redirect]
before_action :authorize_create_environment!, only: [:new, :create]
before_action :authorize_stop_environment!, only: [:stop]
diff --git a/app/controllers/projects/merge_requests/content_controller.rb b/app/controllers/projects/merge_requests/content_controller.rb
index dfc060c9204..399745151b1 100644
--- a/app/controllers/projects/merge_requests/content_controller.rb
+++ b/app/controllers/projects/merge_requests/content_controller.rb
@@ -14,8 +14,6 @@ class Projects::MergeRequests::ContentController < Projects::MergeRequests::Appl
SLOW_POLLING_INTERVAL = 5.minutes.in_milliseconds
def widget
- check_mergeability_async!
-
respond_to do |format|
format.json do
render json: serializer(MergeRequestPollWidgetEntity)
@@ -40,13 +38,6 @@ class Projects::MergeRequests::ContentController < Projects::MergeRequests::Appl
def serializer(entity)
serializer = MergeRequestSerializer.new(current_user: current_user, project: merge_request.project)
- serializer.represent(merge_request, { async_mergeability_check: params[:async_mergeability_check] }, entity)
- end
-
- def check_mergeability_async!
- return unless Feature.enabled?(:check_mergeability_async_in_widget, merge_request.project, default_enabled: :yaml)
- return if params[:async_mergeability_check].blank?
-
- merge_request.check_mergeability(async: true)
+ serializer.represent(merge_request, {}, entity)
end
end
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 91920277c50..7690773354f 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -190,7 +190,6 @@ module IssuesHelper
email: current_user&.notification_email,
emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'),
empty_state_svg_path: image_path('illustrations/issues.svg'),
- endpoint: expose_path(api_v4_projects_issues_path(id: project.id)),
export_csv_path: export_csv_project_issues_path(project),
has_project_issues: project_issues(project).exists?.to_s,
import_csv_issues_path: import_csv_namespace_project_issues_path,
diff --git a/app/models/ability.rb b/app/models/ability.rb
index c18bd21d754..6a63a8d46ba 100644
--- a/app/models/ability.rb
+++ b/app/models/ability.rb
@@ -54,7 +54,7 @@ class Ability
end
end
- def allowed?(user, action, subject = :global, opts = {})
+ def allowed?(user, ability, subject = :global, opts = {})
if subject.is_a?(Hash)
opts = subject
subject = :global
@@ -64,21 +64,76 @@ class Ability
case opts[:scope]
when :user
- DeclarativePolicy.user_scope { policy.can?(action) }
+ DeclarativePolicy.user_scope { policy.allowed?(ability) }
when :subject
- DeclarativePolicy.subject_scope { policy.can?(action) }
+ DeclarativePolicy.subject_scope { policy.allowed?(ability) }
else
- policy.can?(action)
+ policy.allowed?(ability)
end
+ ensure
+ # TODO: replace with runner invalidation:
+ # See: https://gitlab.com/gitlab-org/declarative-policy/-/merge_requests/24
+ # See: https://gitlab.com/gitlab-org/declarative-policy/-/merge_requests/25
+ forget_runner_result(policy.runner(ability)) if policy && ability_forgetting?
end
def policy_for(user, subject = :global)
- cache = Gitlab::SafeRequestStore.active? ? Gitlab::SafeRequestStore : {}
- DeclarativePolicy.policy_for(user, subject, cache: cache)
+ DeclarativePolicy.policy_for(user, subject, cache: ::Gitlab::SafeRequestStore.storage)
+ end
+
+ # This method is something of a band-aid over the problem. The problem is
+ # that some conditions may not be re-entrant, if facts change.
+ # (`BasePolicy#admin?` is a known offender, due to the effects of
+ # `admin_mode`)
+ #
+ # To deal with this we need to clear two elements of state: the offending
+ # conditions (selected by 'pattern') and the cached ability checks (cached
+ # on the `policy#runner(ability)`).
+ #
+ # Clearing the conditions (see `forget_all_but`) is fairly robust, provided
+ # the pattern is not _under_-selective. Clearing the runners is harder,
+ # since there is not good way to know which abilities any given condition
+ # may affect. The approach taken here (see `forget_runner_result`) is to
+ # discard all runner results generated during a `forgetting` block. This may
+ # be _under_-selective if a runner prior to this block cached a state value
+ # that might now be invalid.
+ #
+ # TODO: add some kind of reverse-dependency mapping in DeclarativePolicy
+ # See: https://gitlab.com/gitlab-org/declarative-policy/-/issues/14
+ def forgetting(pattern, &block)
+ was_forgetting = ability_forgetting?
+ ::Gitlab::SafeRequestStore[:ability_forgetting] = true
+ keys_before = ::Gitlab::SafeRequestStore.storage.keys
+
+ yield
+ ensure
+ ::Gitlab::SafeRequestStore[:ability_forgetting] = was_forgetting
+ forget_all_but(keys_before, matching: pattern)
end
private
+ def ability_forgetting?
+ ::Gitlab::SafeRequestStore[:ability_forgetting]
+ end
+
+ def forget_all_but(keys_before, matching:)
+ keys_after = ::Gitlab::SafeRequestStore.storage.keys
+
+ added_keys = keys_after - keys_before
+ added_keys.each do |key|
+ if key.is_a?(String) && key.start_with?('/dp') && key =~ matching
+ ::Gitlab::SafeRequestStore.delete(key)
+ end
+ end
+ end
+
+ def forget_runner_result(runner)
+ # TODO: add support in DP for this
+ # See: https://gitlab.com/gitlab-org/declarative-policy/-/issues/15
+ runner.instance_variable_set(:@state, nil)
+ end
+
def apply_filters_if_needed(elements, user, filters)
filters.each do |ability, filter|
elements = filter.call(elements) unless allowed?(user, ability)
diff --git a/app/models/analytics/cycle_analytics/project_level.rb b/app/models/analytics/cycle_analytics/project_level.rb
index 7a73bc75ed6..d43793f60c9 100644
--- a/app/models/analytics/cycle_analytics/project_level.rb
+++ b/app/models/analytics/cycle_analytics/project_level.rb
@@ -47,3 +47,4 @@ module Analytics
end
end
end
+Analytics::CycleAnalytics::ProjectLevel.prepend_mod_with('Analytics::CycleAnalytics::ProjectLevel')
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index ae06bea5a02..159d9d10878 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -1257,7 +1257,7 @@ module Ci
end
def build_matchers
- self.builds.build_matchers(project)
+ self.builds.latest.build_matchers(project)
end
private
diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb
index da5f4cc1862..7f5f87e3e36 100644
--- a/app/models/clusters/platforms/kubernetes.rb
+++ b/app/models/clusters/platforms/kubernetes.rb
@@ -98,11 +98,7 @@ module Clusters
pods = read_pods(environment.deployment_namespace)
deployments = read_deployments(environment.deployment_namespace)
- ingresses = if ::Feature.enabled?(:canary_ingress_weight_control, environment.project, default_enabled: true)
- read_ingresses(environment.deployment_namespace)
- else
- []
- end
+ ingresses = read_ingresses(environment.deployment_namespace)
# extract only the data required for display to avoid unnecessary caching
{
diff --git a/app/models/container_expiration_policy.rb b/app/models/container_expiration_policy.rb
index 0441a5f0f5b..9bacd9a0edf 100644
--- a/app/models/container_expiration_policy.rb
+++ b/app/models/container_expiration_policy.rb
@@ -38,6 +38,16 @@ class ContainerExpirationPolicy < ApplicationRecord
)
end
+ def self.without_container_repositories
+ where.not(
+ 'EXISTS(?)',
+ ContainerRepository.select(1)
+ .where(
+ 'container_repositories.project_id = container_expiration_policies.project_id'
+ )
+ )
+ end
+
def self.keep_n_options
{
1 => _('%{tags} tag per image name') % { tags: 1 },
diff --git a/app/models/integration.rb b/app/models/integration.rb
index 238ecbbf209..2fbcdc7f1cb 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -44,6 +44,10 @@ class Integration < ApplicationRecord
bamboo bugzilla buildkite
campfire confluence custom_issue_tracker
datadog discord drone_ci
+ emails_on_push ewm emails_on_push external_wiki
+ flowdock
+ hangouts_chat
+ irker
].to_set.freeze
def self.renamed?(name)
diff --git a/app/models/issue.rb b/app/models/issue.rb
index b0a126c4442..48f388ea48d 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -23,6 +23,7 @@ class Issue < ApplicationRecord
include IssueAvailableFeatures
include Todoable
include FromUnion
+ include EachBatch
extend ::Gitlab::Utils::Override
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 15f112690d5..68fb957759d 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -263,8 +263,9 @@ class MergeRequest < ApplicationRecord
scope :by_milestone, ->(milestone) { where(milestone_id: milestone) }
scope :of_projects, ->(ids) { where(target_project_id: ids) }
scope :from_project, ->(project) { where(source_project_id: project.id) }
+ scope :from_fork, -> { where('source_project_id <> target_project_id') }
scope :from_and_to_forks, ->(project) do
- where('source_project_id <> target_project_id AND (source_project_id = ? OR target_project_id = ?)', project.id, project.id)
+ from_fork.where('source_project_id = ? OR target_project_id = ?', project.id, project.id)
end
scope :merged, -> { with_state(:merged) }
scope :closed_and_merged, -> { with_states(:closed, :merged) }
diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb
index 7b0bb72940e..b040c98ef09 100644
--- a/app/models/packages/package.rb
+++ b/app/models/packages/package.rb
@@ -158,7 +158,7 @@ class Packages::Package < ApplicationRecord
joins(:project).reorder(keyset_order)
end
- after_commit :update_composer_cache, on: :destroy, if: -> { composer? }
+ after_commit :update_composer_cache, on: :destroy, if: -> { composer? && Feature.disabled?(:disable_composer_callback) }
def self.only_maven_packages_with_path(path, use_cte: false)
if use_cte
diff --git a/app/models/project.rb b/app/models/project.rb
index 735dc185575..1f8e8b81015 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -166,12 +166,12 @@ class Project < ApplicationRecord
has_one :datadog_integration, class_name: 'Integrations::Datadog'
has_one :discord_integration, class_name: 'Integrations::Discord'
has_one :drone_ci_integration, class_name: 'Integrations::DroneCi'
- has_one :emails_on_push_service, class_name: 'Integrations::EmailsOnPush'
- has_one :ewm_service, class_name: 'Integrations::Ewm'
- has_one :external_wiki_service, class_name: 'Integrations::ExternalWiki'
- has_one :flowdock_service, class_name: 'Integrations::Flowdock'
- has_one :hangouts_chat_service, class_name: 'Integrations::HangoutsChat'
- has_one :irker_service, class_name: 'Integrations::Irker'
+ has_one :emails_on_push_integration, class_name: 'Integrations::EmailsOnPush'
+ has_one :ewm_integration, class_name: 'Integrations::Ewm'
+ has_one :external_wiki_integration, class_name: 'Integrations::ExternalWiki'
+ has_one :flowdock_integration, class_name: 'Integrations::Flowdock'
+ has_one :hangouts_chat_integration, class_name: 'Integrations::HangoutsChat'
+ has_one :irker_integration, class_name: 'Integrations::Irker'
has_one :jenkins_service, class_name: 'Integrations::Jenkins'
has_one :jira_service, class_name: 'Integrations::Jira'
has_one :mattermost_service, class_name: 'Integrations::Mattermost'
@@ -825,6 +825,21 @@ class Project < ApplicationRecord
from_union([with_issues_enabled, with_merge_requests_enabled]).select(:id)
end
+
+ def find_by_url(url)
+ uri = URI(url)
+
+ return unless uri.host == Gitlab.config.gitlab.host
+
+ match = Rails.application.routes.recognize_path(url)
+
+ return if match[:unmatched_route].present?
+ return if match[:namespace_id].blank? || match[:id].blank?
+
+ find_by_full_path(match.values_at(:namespace_id, :id).join("/"))
+ rescue ActionController::RoutingError, URI::InvalidURIError
+ nil
+ end
end
def initialize(attributes = nil)
diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb
index c3ca90ca0ad..a700f104150 100644
--- a/app/models/remote_mirror.rb
+++ b/app/models/remote_mirror.rb
@@ -100,10 +100,11 @@ class RemoteMirror < ApplicationRecord
update_status == 'started'
end
- def update_repository
+ def update_repository(inmemory_remote:)
Gitlab::Git::RemoteMirror.new(
project.repository.raw,
remote_name,
+ inmemory_remote ? remote_url : nil,
**options_for_update
).update
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 8ee0421e45f..5fbd6271589 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -84,10 +84,11 @@ class User < ApplicationRecord
update_tracked_fields(request)
- lease = Gitlab::ExclusiveLease.new("user_update_tracked_fields:#{id}", timeout: 1.hour.to_i)
- return unless lease.try_obtain
-
- Users::UpdateService.new(self, user: self).execute(validate: false)
+ Gitlab::ExclusiveLease.throttle(id) do
+ ::Ability.forgetting(/admin/) do
+ Users::UpdateService.new(self, user: self).execute(validate: false)
+ end
+ end
end
# rubocop: enable CodeReuse/ServiceClass
@@ -1868,6 +1869,12 @@ class User < ApplicationRecord
!!(password_expires_at && password_expires_at < Time.current)
end
+ def password_expired_if_applicable?
+ return false unless allow_password_authentication?
+
+ password_expired?
+ end
+
def can_be_deactivated?
active? && no_recent_activity? && !internal?
end
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index 0f7a6b852ab..77897c5807f 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -27,6 +27,10 @@ class BasePolicy < DeclarativePolicy::Base
with_options scope: :user, score: 0
condition(:security_bot) { @user&.security_bot? }
+ desc "User is automation bot"
+ with_options scope: :user, score: 0
+ condition(:automation_bot) { @user&.automation_bot? }
+
desc "User email is unconfirmed or user account is locked"
with_options scope: :user, score: 0
condition(:inactive) { @user&.confirmation_required_on_sign_in? || @user&.access_locked? }
@@ -63,7 +67,7 @@ class BasePolicy < DeclarativePolicy::Base
rule { default }.enable :read_cross_project
- condition(:is_gitlab_com) { ::Gitlab.dev_env_or_com? }
+ condition(:is_gitlab_com, score: 0, scope: :global) { ::Gitlab.dev_env_or_com? }
end
BasePolicy.prepend_mod_with('BasePolicy')
diff --git a/app/policies/concerns/policy_actor.rb b/app/policies/concerns/policy_actor.rb
index cbc34bdeed3..8fa09683b06 100644
--- a/app/policies/concerns/policy_actor.rb
+++ b/app/policies/concerns/policy_actor.rb
@@ -53,6 +53,10 @@ module PolicyActor
false
end
+ def automation_bot?
+ false
+ end
+
def deactivated?
false
end
@@ -81,7 +85,7 @@ module PolicyActor
false
end
- def password_expired?
+ def password_expired_if_applicable?
false
end
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index 35d38bac7fa..c3b4b163cb4 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -16,7 +16,7 @@ class GlobalPolicy < BasePolicy
end
condition(:password_expired, scope: :user) do
- @user&.password_expired?
+ @user&.password_expired_if_applicable?
end
condition(:project_bot, scope: :user) { @user&.project_bot? }
diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb
index c00dceadf22..3ce67d92af1 100644
--- a/app/serializers/merge_request_poll_widget_entity.rb
+++ b/app/serializers/merge_request_poll_widget_entity.rb
@@ -31,7 +31,6 @@ class MergeRequestPollWidgetEntity < Grape::Entity
expose :mergeable do |merge_request, options|
next merge_request.mergeable? if Feature.disabled?(:check_mergeability_async_in_widget, merge_request.project, default_enabled: :yaml)
- next false if options[:async_mergeability_check].present? && merge_request.checking?
merge_request.mergeable?
end
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index ac9970579ed..0616d94a1ed 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -36,7 +36,7 @@ class MergeRequestWidgetEntity < Grape::Entity
end
expose :merge_request_widget_path do |merge_request|
- widget_project_json_merge_request_path(merge_request.target_project, merge_request, async_mergeability_check: true, format: :json)
+ widget_project_json_merge_request_path(merge_request.target_project, merge_request, format: :json)
end
expose :merge_request_cached_widget_path do |merge_request|
diff --git a/app/services/environments/canary_ingress/update_service.rb b/app/services/environments/canary_ingress/update_service.rb
index 2b510280873..f9813e5e86d 100644
--- a/app/services/environments/canary_ingress/update_service.rb
+++ b/app/services/environments/canary_ingress/update_service.rb
@@ -34,10 +34,6 @@ module Environments
private
def validate(environment)
- unless Feature.enabled?(:canary_ingress_weight_control, environment.project, default_enabled: true)
- return error(_("Feature flag is not enabled on the environment's project."))
- end
-
unless can?(current_user, :update_environment, environment)
return error(_('You do not have permission to update the environment.'))
end
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index 3a4e3ba38fd..f7a0f90b95f 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -294,14 +294,14 @@ module MergeRequests
@source_merge_requests ||= merge_requests_for(@push.branch_name)
end
- # rubocop: disable CodeReuse/ActiveRecord
def merge_requests_for_forks
@merge_requests_for_forks ||=
- MergeRequest.opened
- .where(source_branch: @push.branch_name, source_project: @project)
- .where.not(target_project: @project)
+ MergeRequest
+ .opened
+ .from_project(project)
+ .from_source_branches(@push.branch_name)
+ .from_fork
end
- # rubocop: enable CodeReuse/ActiveRecord
end
end
diff --git a/app/services/packages/helm/process_file_service.rb b/app/services/packages/helm/process_file_service.rb
new file mode 100644
index 00000000000..31b357c1616
--- /dev/null
+++ b/app/services/packages/helm/process_file_service.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+module Packages
+ module Helm
+ class ProcessFileService
+ include Gitlab::Utils::StrongMemoize
+ include ExclusiveLeaseGuard
+
+ ExtractionError = Class.new(StandardError)
+ DEFAULT_LEASE_TIMEOUT = 1.hour.to_i
+
+ def initialize(channel, package_file)
+ @channel = channel
+ @package_file = package_file
+ end
+
+ def execute
+ raise ExtractionError, 'Helm chart was not processed - package_file is not set' unless package_file
+
+ try_obtain_lease do
+ temp_package.transaction do
+ rename_package_and_set_version
+ rename_package_file_and_set_metadata
+ cleanup_temp_package
+ end
+ end
+ end
+
+ private
+
+ attr_reader :channel, :package_file
+
+ def rename_package_and_set_version
+ package.update!(
+ name: metadata['name'],
+ version: metadata['version'],
+ status: :default
+ )
+ end
+
+ def rename_package_file_and_set_metadata
+ # Updating file_name updates the path where the file is stored.
+ # We must pass the file again so that CarrierWave can handle the update
+ package_file.update!(
+ file_name: file_name,
+ file: package_file.file,
+ package_id: package.id,
+ helm_file_metadatum_attributes: {
+ channel: channel,
+ metadata: metadata
+ }
+ )
+ end
+
+ def cleanup_temp_package
+ temp_package.destroy if package.id != temp_package.id
+ end
+
+ def temp_package
+ strong_memoize(:temp_package) do
+ package_file.package
+ end
+ end
+
+ def package
+ strong_memoize(:package) do
+ project_packages = package_file.package.project.packages
+ package = project_packages.with_package_type(:helm)
+ .with_name(metadata['name'])
+ .with_version(metadata['version'])
+ .last
+ package || temp_package
+ end
+ end
+
+ def metadata
+ strong_memoize(:metadata) do
+ ::Packages::Helm::ExtractFileMetadataService.new(package_file).execute
+ end
+ end
+
+ def file_name
+ "#{metadata['name']}-#{metadata['version']}.tgz"
+ end
+
+ # used by ExclusiveLeaseGuard
+ def lease_key
+ "packages:helm:process_file_service:package_file:#{package_file.id}"
+ end
+
+ # used by ExclusiveLeaseGuard
+ def lease_timeout
+ DEFAULT_LEASE_TIMEOUT
+ end
+ end
+ end
+end
diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb
index 9f4f6133d92..eac84337967 100644
--- a/app/services/projects/update_remote_mirror_service.rb
+++ b/app/services/projects/update_remote_mirror_service.rb
@@ -39,12 +39,16 @@ module Projects
def update_mirror(remote_mirror)
remote_mirror.update_start!
- remote_mirror.ensure_remote!
# LFS objects must be sent first, or the push has dangling pointers
send_lfs_objects!(remote_mirror)
- response = remote_mirror.update_repository
+ response = if Feature.enabled?(:update_remote_mirror_inmemory, project, default_enabled: :yaml)
+ remote_mirror.update_repository(inmemory_remote: true)
+ else
+ remote_mirror.ensure_remote!
+ remote_mirror.update_repository(inmemory_remote: false)
+ end
if response.divergent_refs.any?
message = "Some refs have diverged and have not been updated on the remote:"
diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb
index ff08c806319..23c67231a29 100644
--- a/app/services/users/update_service.rb
+++ b/app/services/users/update_service.rb
@@ -17,6 +17,7 @@ module Users
yield(@user) if block_given?
user_exists = @user.persisted?
+ @user.user_detail # prevent assignment
discard_read_only_attributes
assign_attributes
diff --git a/app/views/admin/users/_cohorts.html.haml b/app/views/admin/cohorts/_cohorts.html.haml
index 25b30adc5be..25b30adc5be 100644
--- a/app/views/admin/users/_cohorts.html.haml
+++ b/app/views/admin/cohorts/_cohorts.html.haml
diff --git a/app/views/admin/users/_cohorts_table.html.haml b/app/views/admin/cohorts/_cohorts_table.html.haml
index a92cfb5851a..a92cfb5851a 100644
--- a/app/views/admin/users/_cohorts_table.html.haml
+++ b/app/views/admin/cohorts/_cohorts_table.html.haml
diff --git a/app/views/admin/users/cohorts.html.haml b/app/views/admin/cohorts/index.html.haml
index 3f3d22fa410..7ba4cd6d733 100644
--- a/app/views/admin/users/cohorts.html.haml
+++ b/app/views/admin/cohorts/index.html.haml
@@ -1,6 +1,6 @@
- page_title _("Users")
-= render 'tabs'
+= render 'admin/users/tabs'
.tab-content
.tab-pane.active
diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml
index d03a782756b..6f3c16f7abf 100644
--- a/app/views/admin/runners/show.html.haml
+++ b/app/views/admin/runners/show.html.haml
@@ -28,12 +28,14 @@
%tr
%td
.gl-alert.gl-alert-danger
- = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-body
- %strong
- = project.full_name
- .gl-alert-actions
- = link_to s_('Disable'), admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'btn gl-alert-action btn-info btn-md gl-button'
+ .gl-alert-container
+ = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-content
+ .gl-alert-body
+ %strong
+ = project.full_name
+ .gl-alert-actions
+ = link_to s_('Disable'), admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'btn gl-alert-action btn-confirm btn-md gl-button'
%table.table{ data: { testid: 'unassigned-projects' } }
%thead
diff --git a/app/views/admin/users/_tabs.html.haml b/app/views/admin/users/_tabs.html.haml
index 1a3239897eb..90f06eeaf3f 100644
--- a/app/views/admin/users/_tabs.html.haml
+++ b/app/views/admin/users/_tabs.html.haml
@@ -3,5 +3,5 @@
%a.nav-link{ href: admin_users_path, class: active_when(current_page?(admin_users_path)), role: 'tab' }
= s_('AdminUsers|Users')
%li.nav-item{ role: 'presentation' }
- %a.nav-link{ href: cohorts_admin_users_path, class: active_when(current_page?(cohorts_admin_users_path)), role: 'tab' }
+ %a.nav-link{ href: admin_cohorts_path, class: active_when(current_page?(admin_cohorts_path)), role: 'tab' }
= s_('AdminUsers|Cohorts')
diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
index 5df368ef3af..81f4be9fce5 100644
--- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
+++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
@@ -1,9 +1,11 @@
- link = link_to(s_('ClusterIntegration|sign up'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer')
.gcp-signup-offer.gl-alert.gl-alert-info.gl-my-3{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } }
- %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
- = sprite_icon('close', size: 16, css_class: 'gl-icon')
- = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- %h4.gl-alert-title= s_('ClusterIntegration|Did you know?')
- %p.gl-alert-body= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link }
- %a.gl-button.btn-confirm.text-decoration-none{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' }
- = s_("ClusterIntegration|Apply for credit")
+ .gl-alert-container
+ %button.js-close.btn.gl-dismiss-btn.btn-default.btn-sm.gl-button.btn-default-tertiary.btn-icon{ type: 'button', 'aria-label' => _('Dismiss') }
+ = sprite_icon('close', size: 16, css_class: 'gl-icon')
+ = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-content
+ %h4.gl-alert-title= s_('ClusterIntegration|Did you know?')
+ %p.gl-alert-body= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link }
+ %a.gl-button.btn-confirm.text-decoration-none{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' }
+ = s_("ClusterIntegration|Apply for credit")
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index 7a80c4e0ba9..21c3d7cb7e2 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -10,14 +10,14 @@
%span.sidebar-context-title
= _('Admin Area')
%ul.sidebar-top-level-items{ data: { qa_selector: 'admin_sidebar_overview_submenu_content' } }
- = nav_link(controller: %w(dashboard admin admin/projects users groups jobs runners gitaly_servers), html_options: {class: 'home'}) do
+ = nav_link(controller: %w(dashboard admin admin/projects users groups jobs runners gitaly_servers cohorts), html_options: {class: 'home'}) do
= link_to admin_root_path, class: 'has-sub-items' do
.nav-icon-container
= sprite_icon('overview')
%span.nav-item-name
= _('Overview')
%ul.sidebar-sub-level-items
- = nav_link(controller: %w(dashboard admin admin/projects users groups jobs runners gitaly_servers), html_options: { class: "fly-out-top-item" } ) do
+ = nav_link(controller: %w(dashboard admin admin/projects users groups jobs runners gitaly_servers cohorts), html_options: { class: "fly-out-top-item" } ) do
= link_to admin_root_path do
%strong.fly-out-top-item-name
= _('Overview')
@@ -30,7 +30,7 @@
= link_to admin_projects_path, title: _('Projects') do
%span
= _('Projects')
- = nav_link(controller: :users) do
+ = nav_link(controller: %w(users cohorts)) do
= link_to admin_users_path, title: _('Users'), data: { qa_selector: 'users_overview_link' } do
%span
= _('Users')
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 49f2795538c..691ce8dc5fc 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -62,7 +62,7 @@
- add_page_startup_api_call notes_url
- else
- add_page_startup_api_call discussions_path(@merge_request)
- - add_page_startup_api_call widget_project_json_merge_request_path(@project, @merge_request, async_mergeability_check: true, format: :json)
+ - add_page_startup_api_call widget_project_json_merge_request_path(@project, @merge_request, format: :json)
- add_page_startup_api_call cached_widget_project_json_merge_request_path(@project, @merge_request, format: :json)
#js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request, Feature.enabled?(:paginated_notes, @project)).to_json,
endpoint_metadata: @endpoint_metadata_url,
diff --git a/app/workers/container_expiration_policy_worker.rb b/app/workers/container_expiration_policy_worker.rb
index b15d1bf90bd..8fc139ac87c 100644
--- a/app/workers/container_expiration_policy_worker.rb
+++ b/app/workers/container_expiration_policy_worker.rb
@@ -15,11 +15,19 @@ class ContainerExpirationPolicyWorker # rubocop:disable Scalability/IdempotentWo
def perform
process_stale_ongoing_cleanups
+ disable_policies_without_container_repositories
throttling_enabled? ? perform_throttled : perform_unthrottled
end
private
+ def disable_policies_without_container_repositories
+ ContainerExpirationPolicy.active.each_batch(of: BATCH_SIZE) do |policies|
+ policies.without_container_repositories
+ .update_all(enabled: false)
+ end
+ end
+
def process_stale_ongoing_cleanups
threshold = delete_tags_service_timeout.seconds + 30.minutes
ContainerRepository.with_stale_ongoing_cleanup(threshold.ago)
diff --git a/app/workers/web_hook_worker.rb b/app/workers/web_hook_worker.rb
index 3480f49d640..a2a53ca922a 100644
--- a/app/workers/web_hook_worker.rb
+++ b/app/workers/web_hook_worker.rb
@@ -8,7 +8,7 @@ class WebHookWorker
feature_category :integrations
worker_has_external_dependencies!
loggable_arguments 2
- data_consistency :delayed, feature_flag: :load_balancing_for_web_hook_worker
+ data_consistency :delayed
sidekiq_options retry: 4, dead: false
diff --git a/config/feature_flags/development/block_external_fork_network_mirrors.yml b/config/feature_flags/development/block_external_fork_network_mirrors.yml
new file mode 100644
index 00000000000..81ff34a3d6e
--- /dev/null
+++ b/config/feature_flags/development/block_external_fork_network_mirrors.yml
@@ -0,0 +1,8 @@
+---
+name: block_external_fork_network_mirrors
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/60735
+rollout_issue_url:
+milestone: '14.0'
+type: development
+group: group::source code
+default_enabled: false
diff --git a/config/feature_flags/development/load_balancing_for_web_hook_worker.yml b/config/feature_flags/development/disable_composer_callback.yml
index f9c191e3ab4..93861e50409 100644
--- a/config/feature_flags/development/load_balancing_for_web_hook_worker.yml
+++ b/config/feature_flags/development/disable_composer_callback.yml
@@ -1,8 +1,8 @@
---
-name: load_balancing_for_web_hook_worker
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/62075
-rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/331365
+name: disable_composer_callback
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64016
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/333587
milestone: '14.0'
type: development
-group: group::memory
+group: group::package
default_enabled: false
diff --git a/config/feature_flags/development/canary_ingress_weight_control.yml b/config/feature_flags/development/update_remote_mirror_inmemory.yml
index 66d3f2de0ea..e1d347ffa7e 100644
--- a/config/feature_flags/development/canary_ingress_weight_control.yml
+++ b/config/feature_flags/development/update_remote_mirror_inmemory.yml
@@ -1,8 +1,8 @@
---
-name: canary_ingress_weight_control
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/43816
-rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/260295
-milestone: '13.5'
+name: update_remote_mirror_inmemory
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/63962
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/333517
+milestone: '14.0'
type: development
-group: group::release
-default_enabled: true
+group: group::gitaly
+default_enabled: false
diff --git a/config/initializers/active_record_keyset_pagination.rb b/config/initializers/active_record_keyset_pagination.rb
index f8c2ada5255..f5692c95276 100644
--- a/config/initializers/active_record_keyset_pagination.rb
+++ b/config/initializers/active_record_keyset_pagination.rb
@@ -2,8 +2,8 @@
module PaginatorExtension
# This method loads the records for the requested page and returns a keyset paginator object.
- def keyset_paginate(cursor: nil, per_page: 20)
- Gitlab::Pagination::Keyset::Paginator.new(scope: self.dup, cursor: cursor, per_page: per_page)
+ def keyset_paginate(cursor: nil, per_page: 20, keyset_order_options: {})
+ Gitlab::Pagination::Keyset::Paginator.new(scope: self.dup, cursor: cursor, per_page: per_page, keyset_order_options: keyset_order_options)
end
end
diff --git a/config/initializers/declarative_policy.rb b/config/initializers/declarative_policy.rb
index 49267584809..406f88bac23 100644
--- a/config/initializers/declarative_policy.rb
+++ b/config/initializers/declarative_policy.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+require 'declarative_policy'
+
DeclarativePolicy.configure do
named_policy :global, ::GlobalPolicy
end
diff --git a/config/routes/admin.rb b/config/routes/admin.rb
index 7bd24ac5f5b..4d25f24a104 100644
--- a/config/routes/admin.rb
+++ b/config/routes/admin.rb
@@ -10,12 +10,6 @@ namespace :admin do
end
end
- collection do
- scope '/-/' do
- get :cohorts
- end
- end
-
member do
get :projects
get :keys
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index f315a8cb28e..283683d22a1 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -196,6 +196,8 @@
- 2
- - issue_rebalancing
- 1
+- - iterations
+ - 1
- - jira_connect
- 1
- - jira_importer
diff --git a/config/webpack.config.js b/config/webpack.config.js
index db5371a7258..c2af7197f94 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -103,6 +103,15 @@ function generateEntries() {
autoEntries[entry] = defaultEntries.concat(entryPaths);
});
+ /*
+ If you create manual entries, ensure that these import `app/assets/javascripts/webpack.js` right at
+ the top of the entry in order to ensure that the public path is correctly determined for loading
+ assets async. See: https://webpack.js.org/configuration/output/#outputpublicpath
+
+ Note: WebPack 5 has an 'auto' option for the public path which could allow us to remove this option
+ Note 2: If you are using web-workers, you might need to reset the public path, see:
+ https://gitlab.com/gitlab-org/gitlab/-/issues/321656
+ */
const manualEntries = {
default: defaultEntries,
sentry: './sentry/index.js',
diff --git a/db/migrate/20210608103230_add_issue_id_to_test_report.rb b/db/migrate/20210608103230_add_issue_id_to_test_report.rb
new file mode 100644
index 00000000000..f4e723d0af8
--- /dev/null
+++ b/db/migrate/20210608103230_add_issue_id_to_test_report.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AddIssueIdToTestReport < ActiveRecord::Migration[6.1]
+ include Gitlab::Database::MigrationHelpers
+
+ def up
+ with_lock_retries do
+ add_column :requirements_management_test_reports, :issue_id, :bigint, null: true
+ end
+ end
+
+ def down
+ with_lock_retries do
+ remove_column :requirements_management_test_reports, :issue_id
+ end
+ end
+end
diff --git a/db/migrate/20210608103235_add_issue_index_to_test_report.rb b/db/migrate/20210608103235_add_issue_index_to_test_report.rb
new file mode 100644
index 00000000000..41f1970b2a9
--- /dev/null
+++ b/db/migrate/20210608103235_add_issue_index_to_test_report.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AddIssueIndexToTestReport < ActiveRecord::Migration[6.1]
+ include Gitlab::Database::MigrationHelpers
+
+ disable_ddl_transaction!
+
+ INDEX_NAME = 'index_requirements_management_test_reports_on_issue_id'
+
+ def up
+ add_concurrent_index :requirements_management_test_reports, :issue_id, name: INDEX_NAME
+ end
+
+ def down
+ remove_concurrent_index_by_name :requirements_management_test_reports, INDEX_NAME
+ end
+end
diff --git a/db/migrate/20210608110752_change_column_null_test_report_requirement.rb b/db/migrate/20210608110752_change_column_null_test_report_requirement.rb
new file mode 100644
index 00000000000..44a614a34ce
--- /dev/null
+++ b/db/migrate/20210608110752_change_column_null_test_report_requirement.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class ChangeColumnNullTestReportRequirement < ActiveRecord::Migration[6.1]
+ include Gitlab::Database::MigrationHelpers
+
+ TARGET_TABLE = :requirements_management_test_reports
+
+ def up
+ with_lock_retries do
+ change_column_null TARGET_TABLE, :requirement_id, true
+ end
+ end
+
+ def down
+ # no-op as it's difficult to revert
+ end
+end
diff --git a/db/migrate/20210608110760_add_requirement_test_reports_foreign_key.rb b/db/migrate/20210608110760_add_requirement_test_reports_foreign_key.rb
new file mode 100644
index 00000000000..e256bce6ae0
--- /dev/null
+++ b/db/migrate/20210608110760_add_requirement_test_reports_foreign_key.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class AddRequirementTestReportsForeignKey < ActiveRecord::Migration[6.1]
+ include Gitlab::Database::MigrationHelpers
+
+ disable_ddl_transaction!
+
+ TARGET_TABLE = :requirements_management_test_reports
+ CONSTRAINT_NAME = 'requirements_test_reports_requirement_id_xor_issue_id'
+
+ def up
+ add_concurrent_foreign_key TARGET_TABLE, :issues, column: :issue_id
+
+ add_check_constraint(TARGET_TABLE, 'num_nonnulls(requirement_id, issue_id) = 1', CONSTRAINT_NAME)
+ end
+
+ def down
+ remove_check_constraint TARGET_TABLE, CONSTRAINT_NAME
+
+ with_lock_retries do
+ remove_foreign_key_if_exists(TARGET_TABLE, column: :issue_id)
+ end
+ end
+end
diff --git a/db/migrate/20210614143954_add_unique_index_for_helm_packages.rb b/db/migrate/20210614143954_add_unique_index_for_helm_packages.rb
new file mode 100644
index 00000000000..e6b7ba7616d
--- /dev/null
+++ b/db/migrate/20210614143954_add_unique_index_for_helm_packages.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class AddUniqueIndexForHelmPackages < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ INDEX_NAME = 'index_packages_on_project_id_name_version_unique_when_helm'
+ PACKAGE_TYPE_HELM = 11
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :packages_packages, [:project_id, :name, :version], unique: true, where: "package_type = #{PACKAGE_TYPE_HELM}", name: INDEX_NAME
+ end
+
+ def down
+ remove_concurrent_index_by_name :packages_packages, INDEX_NAME
+ end
+end
diff --git a/db/schema_migrations/20210608103230 b/db/schema_migrations/20210608103230
new file mode 100644
index 00000000000..541faa3aab7
--- /dev/null
+++ b/db/schema_migrations/20210608103230
@@ -0,0 +1 @@
+12d8de65d287cf29fa2761264c42eb42e7fe2a5b36c279e623d93897503b5313 \ No newline at end of file
diff --git a/db/schema_migrations/20210608103235 b/db/schema_migrations/20210608103235
new file mode 100644
index 00000000000..601c374e620
--- /dev/null
+++ b/db/schema_migrations/20210608103235
@@ -0,0 +1 @@
+fc503b8e9672eb5638d2cb3468c8df4d9c0d998332909351121ace04d3f7214a \ No newline at end of file
diff --git a/db/schema_migrations/20210608110752 b/db/schema_migrations/20210608110752
new file mode 100644
index 00000000000..5c4a1f16971
--- /dev/null
+++ b/db/schema_migrations/20210608110752
@@ -0,0 +1 @@
+cbe4cff5937f3ba39a4aeeed78dcc6dc6ece212b01b16bfcd61ccf4a20427dcc \ No newline at end of file
diff --git a/db/schema_migrations/20210608110760 b/db/schema_migrations/20210608110760
new file mode 100644
index 00000000000..46ace509e0d
--- /dev/null
+++ b/db/schema_migrations/20210608110760
@@ -0,0 +1 @@
+b84505713afce3bf0673329a3a4eaf85a00d4f8948f56d43d365d6cc47ef629c \ No newline at end of file
diff --git a/db/schema_migrations/20210614143954 b/db/schema_migrations/20210614143954
new file mode 100644
index 00000000000..7fd3ce9b49e
--- /dev/null
+++ b/db/schema_migrations/20210614143954
@@ -0,0 +1 @@
+b958d65f1b3b43d7bcd2a703489132ba9a2ba1e0374d45533399355ce6be9365 \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index fcac180738c..6de556b602f 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -17498,10 +17498,12 @@ ALTER SEQUENCE requirements_id_seq OWNED BY requirements.id;
CREATE TABLE requirements_management_test_reports (
id bigint NOT NULL,
created_at timestamp with time zone NOT NULL,
- requirement_id bigint NOT NULL,
+ requirement_id bigint,
author_id bigint,
state smallint NOT NULL,
- build_id bigint
+ build_id bigint,
+ issue_id bigint,
+ CONSTRAINT requirements_test_reports_requirement_id_xor_issue_id CHECK ((num_nonnulls(requirement_id, issue_id) = 1))
);
CREATE SEQUENCE requirements_management_test_reports_id_seq
@@ -24115,6 +24117,8 @@ CREATE UNIQUE INDEX index_packages_on_project_id_name_version_unique_when_generi
CREATE UNIQUE INDEX index_packages_on_project_id_name_version_unique_when_golang ON packages_packages USING btree (project_id, name, version) WHERE (package_type = 8);
+CREATE UNIQUE INDEX index_packages_on_project_id_name_version_unique_when_helm ON packages_packages USING btree (project_id, name, version) WHERE (package_type = 11);
+
CREATE INDEX index_packages_package_file_build_infos_on_package_file_id ON packages_package_file_build_infos USING btree (package_file_id);
CREATE INDEX index_packages_package_file_build_infos_on_pipeline_id ON packages_package_file_build_infos USING btree (pipeline_id);
@@ -24489,6 +24493,8 @@ CREATE INDEX index_requirements_management_test_reports_on_author_id ON requirem
CREATE INDEX index_requirements_management_test_reports_on_build_id ON requirements_management_test_reports USING btree (build_id);
+CREATE INDEX index_requirements_management_test_reports_on_issue_id ON requirements_management_test_reports USING btree (issue_id);
+
CREATE INDEX index_requirements_management_test_reports_on_requirement_id ON requirements_management_test_reports USING btree (requirement_id);
CREATE INDEX index_requirements_on_author_id ON requirements USING btree (author_id);
@@ -25826,6 +25832,9 @@ ALTER TABLE ONLY vulnerabilities
ALTER TABLE ONLY bulk_import_entities
ADD CONSTRAINT fk_88c725229f FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE;
+ALTER TABLE ONLY requirements_management_test_reports
+ ADD CONSTRAINT fk_88f30752fc FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE;
+
ALTER TABLE ONLY issues
ADD CONSTRAINT fk_899c8f3231 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
diff --git a/doc/.vale/gitlab/Admin.yml b/doc/.vale/gitlab/Admin.yml
index a2477026560..987dbfdbd09 100644
--- a/doc/.vale/gitlab/Admin.yml
+++ b/doc/.vale/gitlab/Admin.yml
@@ -8,6 +8,6 @@ extends: substitution
message: 'Verify this use of the word "admin". Can it be updated to "administration", "administrator", "administer", or "Admin Area"?'
link: https://docs.gitlab.com/ee/development/documentation/styleguide/index.html
level: suggestion
-ignorecase: true
+ignorecase: false
swap:
- 'admin ?\w*': '(?:Admin (Area|Mode)|[Aa]dminist(ration|rator|rators|er|rative))'
+ '[Aa]dmin ?\w*': '(?:Admin( Area| Mode)?|[Aa]dminist(ration|rator|rators|er|rative))'
diff --git a/doc/administration/database_load_balancing.md b/doc/administration/database_load_balancing.md
index 9c1ed9b3477..e9f989c96ea 100644
--- a/doc/administration/database_load_balancing.md
+++ b/doc/administration/database_load_balancing.md
@@ -113,7 +113,7 @@ Some background jobs can use database replicas to read application state.
This allows to offload the primary database.
Load balancing is disabled by default in Sidekiq. When enabled, we can define
-[the data consistency](../development/sidekiq_style_guide.md#job-data-consistency)
+[the data consistency](../development/sidekiq_style_guide.md#job-data-consistency-strategies)
requirements for a specific job.
To enable it, define the `ENABLE_LOAD_BALANCING_FOR_SIDEKIQ` variable to the environment, as shown below.
diff --git a/doc/administration/troubleshooting/kubernetes_cheat_sheet.md b/doc/administration/troubleshooting/kubernetes_cheat_sheet.md
index b43825092b7..bb0287df596 100644
--- a/doc/administration/troubleshooting/kubernetes_cheat_sheet.md
+++ b/doc/administration/troubleshooting/kubernetes_cheat_sheet.md
@@ -147,7 +147,7 @@ and they will assist you with any issues you are having.
You can also use `gitlab-rake`, instead of `/usr/local/bin/gitlab-rake`.
-- Troubleshooting **Infrastructure > Kubernetes** integration:
+- Troubleshooting **Infrastructure > Kubernetes clusters** integration:
- Check the output of `kubectl get events -w --all-namespaces`.
- Check the logs of pods within `gitlab-managed-apps` namespace.
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 0069d109e1e..e84769fa568 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -14459,9 +14459,9 @@ List limit metric setting.
| Value | Description |
| ----- | ----------- |
-| <a id="listlimitmetricall_metrics"></a>`all_metrics` | |
-| <a id="listlimitmetricissue_count"></a>`issue_count` | |
-| <a id="listlimitmetricissue_weights"></a>`issue_weights` | |
+| <a id="listlimitmetricall_metrics"></a>`all_metrics` | Limit list by number and total weight of issues. |
+| <a id="listlimitmetricissue_count"></a>`issue_count` | Limit list by number of issues. |
+| <a id="listlimitmetricissue_weights"></a>`issue_weights` | Limit list by total weight of issues. |
### `MeasurementIdentifier`
diff --git a/doc/api/group_milestones.md b/doc/api/group_milestones.md
index cf542a9da0b..40d51f90b5a 100644
--- a/doc/api/group_milestones.md
+++ b/doc/api/group_milestones.md
@@ -144,6 +144,11 @@ Parameters:
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `milestone_id` | integer | yes | The ID of a group milestone |
+Currently, this API endpoint doesn't return issues from any subgroups.
+If you want to get all the milestones' issues, you can instead use the
+[List issues API](issues.md#list-issues) and filter for a
+particular milestone (for example, `GET /issues?milestone=1.0.0&state=opened`).
+
## Get all merge requests assigned to a single milestone
Gets all merge requests assigned to a single group milestone.
diff --git a/doc/ci/runners/README.md b/doc/ci/runners/README.md
index b493da993ca..45113c480c7 100644
--- a/doc/ci/runners/README.md
+++ b/doc/ci/runners/README.md
@@ -21,7 +21,7 @@ which depends on your [subscription plan](../../subscriptions/gitlab_com/index.m
Linux shared runners on GitLab.com run in autoscale mode and are powered by Google Cloud Platform.
-Autoscaling means reduced queue times to spin up CI/CD jobs, and isolated VMs for each project, thus maximizing security. These shared runners are available for users and customers on GitLab.com.
+Autoscaling means reduced queue times to spin up CI/CD jobs, and isolated VMs for each job, thus maximizing security. These shared runners are available for users and customers on GitLab.com.
GitLab offers Ultimate tier capabilities and included CI/CD minutes per group per month for our [Open Source](https://about.gitlab.com/solutions/open-source/join/), [Education](https://about.gitlab.com/solutions/education/), and [Startups](https://about.gitlab.com/solutions/startups/) programs. For private projects, GitLab offers various [plans](https://about.gitlab.com/pricing/), starting with a Free tier.
diff --git a/doc/development/database/database_reviewer_guidelines.md b/doc/development/database/database_reviewer_guidelines.md
index de131ddffbc..16734dada13 100644
--- a/doc/development/database/database_reviewer_guidelines.md
+++ b/doc/development/database/database_reviewer_guidelines.md
@@ -52,7 +52,7 @@ that require a more in-depth discussion between the database reviewers and maint
- [Database Office Hours Agenda](https://docs.google.com/document/d/1wgfmVL30F8SdMg-9yY6Y8djPSxWNvKmhR5XmsvYX1EI/edit).
- <i class="fa fa-youtube-play youtube" aria-hidden="true"></i> [YouTube playlist with past recordings](https://www.youtube.com/playlist?list=PL05JrBw4t0Kp-kqXeiF7fF7cFYaKtdqXM).
-You should also join the [#database-labs](../understanding_explain_plans.md#database-lab)
+You should also join the [#database-lab](../understanding_explain_plans.md#database-lab-engine)
Slack channel and get familiar with how to use Joe, the Slackbot that provides developers
with their own clone of the production database.
diff --git a/doc/development/database/keyset_pagination.md b/doc/development/database/keyset_pagination.md
new file mode 100644
index 00000000000..e30c3cc8832
--- /dev/null
+++ b/doc/development/database/keyset_pagination.md
@@ -0,0 +1,251 @@
+---
+stage: Enablement
+group: Database
+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
+---
+
+# Keyset pagination
+
+The keyset pagination library can be used in HAML-based views and the REST API within the GitLab project.
+
+You can read about keyset pagination and how it compares to the offset based pagination on our [pagination guidelines](pagination_guidelines.md) page.
+
+## API overview
+
+### Synopsis
+
+Keyset pagination with `ActiveRecord` in Rails controllers:
+
+```ruby
+cursor = params[:cursor] # this is nil when the first page is requested
+paginator = Project.order(:created_at).keyset_paginate(cursor: cursor, per_page: 20)
+
+paginator.each do |project|
+ puts project.name # prints maximum 20 projects
+end
+```
+
+### Usage
+
+This library adds a single method to ActiveRecord relations: [`#keyset_paginate`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/config/initializers/active_record_keyset_pagination.rb).
+
+This is similar in spirit (but not in implementation) to Kaminari's `paginate` method.
+
+Keyset pagination works without any configuration for simple ActiveRecord queries:
+
+- Order by one column.
+- Order by two columns, where the last column is the primary key.
+
+The library can detect nullable and non-distinct columns and based on these, it will add extra ordering using the primary key. This is necessary because keyset pagination expects distinct order by values:
+
+```ruby
+Project.order(:created_at).keyset_paginate.records # ORDER BY created_at, id
+
+Project.order(:name).keyset_paginate.records # ORDER BY name, id
+
+Project.order(:created_at, id: :desc).keyset_paginate.records # ORDER BY created_at, id
+
+Project.order(created_at: :asc, id: :desc).keyset_paginate.records # ORDER BY created_at, id DESC
+```
+
+The `keyset_paginate` method returns [a special paginator object](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/pagination/keyset/paginator.rb) which contains the loaded records and additional information for requesting various pages.
+
+The method accepts the following keyword arguments:
+
+- `cursor` - Encoded order by column values for requesting the next page (can be `nil`).
+- `per_page` - Number of records to load per page (default 20).
+- `keyset_order_options` - Extra options for building the keyset paginated database query, see an example for `UNION` queries in the performance section (optional).
+
+The paginator object has the following methods:
+
+- `records` - Returns the records for the current page.
+- `has_next_page?` - Tells whether there is a next page.
+- `has_previous_page?` - Tells whether there is a previous page.
+- `cursor_for_next_page` - Encoded values as `String` for requesting the next page (can be `nil`).
+- `cursor_for_previous_page` - Encoded values as `String` for requesting the previous page (can be `nil`).
+- `cursor_for_first_page` - Encoded values as `String` for requesting the first page.
+- `cursor_for_last_page` - Encoded values as `String` for requesting the last page.
+- The paginator objects includes the `Enumerable` module and delegates the enumerable functionality to the `records` method/array.
+
+Example for getting the first and the second page:
+
+```ruby
+paginator = Project.order(:name).keyset_paginate
+
+paginator.to_a # same as .records
+
+cursor = paginator.cursor_for_next_page # encoded column attributes for the next page
+
+paginator = Project.order(:name).keyset_paginate(cursor: cursor).records # loading the next page
+```
+
+Since keyset pagination does not support page numbers, we are restricted to go to the following pages:
+
+- Next page
+- Previous page
+- Last page
+- First page
+
+#### Usage in Rails with HAML views
+
+Consider the following controller action, where we list the projects ordered by name:
+
+```ruby
+def index
+ @projects = Project.order(:name).keyset_paginate(cursor: params[:cursor])
+end
+```
+
+In the HAML file, we can render the records:
+
+```ruby
+- if @projects.any?
+ - @projects.each do |project|
+ .project-container
+ = project.name
+
+ = keyset_paginate @projects
+```
+
+## Performance
+
+The performance of the keyset pagination depends on the database index configuration and the number of columns we use in the `ORDER BY` clause.
+
+In case we order by the primary key (`id`), then the generated queries will be efficient since the primary key is covered by a database index.
+
+When two or more columns are used in the `ORDER BY` clause, it's advised to check the generated database query and make sure that the correct index configuration is used. More information can be found on the [pagination guideline page](pagination_guidelines.md#index-coverage).
+
+NOTE:
+While the query performance of the first page might look good, the second page (where the cursor attributes are used in the query) might yield poor performance. It's advised to always verify the performance of both queries: first page and second page.
+
+Example database query with tie-breaker (`id`) column:
+
+```sql
+SELECT "issues".*
+FROM "issues"
+WHERE (("issues"."id" > 99
+ AND "issues"."created_at" = '2021-02-16 11:26:17.408466')
+ OR ("issues"."created_at" > '2021-02-16 11:26:17.408466')
+ OR ("issues"."created_at" IS NULL))
+ORDER BY "issues"."created_at" DESC NULLS LAST, "issues"."id" DESC
+LIMIT 20
+```
+
+`OR` queries are difficult to optimize in PostgreSQL, we generally advise using [`UNION` queries](../sql.md#use-unions) instead. The keyset pagination library can generate efficient `UNION` when multiple columns are present in the `ORDER BY` clause. This is triggered when we specify the `use_union_optimization: true` option in the options passed to `Relation#keyset_paginate`.
+
+Example:
+
+```ruby
+# Triggers a simple query for the first page.
+paginator1 = Project.order(:created_at, id: :desc).keyset_paginate(per_page: 2, keyset_order_options: { use_union_optimization: true })
+
+cursor = paginator1.cursor_for_next_page
+
+# Triggers UNION query for the second page
+paginator2 = Project.order(:created_at, id: :desc).keyset_paginate(per_page: 2, cursor: cursor, keyset_order_options: { use_union_optimization: true })
+
+puts paginator2.records.to_a # UNION query
+```
+
+## Complex order configuration
+
+Common `ORDER BY` configurations will be handled by the `keyset_paginate` method automatically so no manual configuration is needed. There are a few edge cases where order object configuration is necessary:
+
+- `NULLS LAST` ordering.
+- Function-based ordering.
+- Ordering with a custom tie-breaker column, like `iid`.
+
+These order objects can be defined in the model classes as normal ActiveRecord scopes, there is no special behavior that prevents using these scopes elsewhere (kaminari, background jobs).
+
+### `NULLS LAST` ordering
+
+Consider the following scope:
+
+```ruby
+scope = Issue.where(project_id: 10).order(Gitlab::Database.nulls_last_order('relative_position', 'DESC'))
+# SELECT "issues".* FROM "issues" WHERE "issues"."project_id" = 10 ORDER BY relative_position DESC NULLS LAST
+
+scope.keyset_paginate # raises: Gitlab::Pagination::Keyset::Paginator::UnsupportedScopeOrder: The order on the scope does not support keyset pagination
+```
+
+The `keyset_paginate` method raises an error because the order value on the query is a custom SQL string and not an [`Arel`](https://www.rubydoc.info/gems/arel) AST node. The keyset library cannot automatically infer configuration values from these kinds of queries.
+
+To make keyset pagination work, we need to configure custom order objects, to do so, we need to collect information about the order columns:
+
+- `relative_position` can have duplicated values since no unique index is present.
+- `relative_position` can have null values because we don't have a not null constraint on the column. For this, we need to determine where will we see NULL values, at the beginning of the resultset or the end (`NULLS LAST`).
+- Keyset pagination requires distinct order columns, so we'll need to add the primary key (`id`) to make the order distinct.
+- Jumping to the last page and paginating backwards actually reverses the `ORDER BY` clause. For this, we'll need to provide the reversed `ORDER BY` clause.
+
+Example:
+
+```ruby
+order = Gitlab::Pagination::Keyset::Order.build([
+ # The attributes are documented in the `lib/gitlab/pagination/keyset/column_order_definition.rb` file
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'relative_position',
+ column_expression: Issue.arel_table[:relative_position],
+ order_expression: Gitlab::Database.nulls_last_order('relative_position', 'DESC'),
+ reversed_order_expression: Gitlab::Database.nulls_first_order('relative_position', 'ASC'),
+ nullable: :nulls_last,
+ order_direction: :desc,
+ distinct: false
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'id',
+ order_expression: Issue.arel_table[:id].asc,
+ nullable: :not_nullable,
+ distinct: true
+ )
+])
+
+scope = Issue.where(project_id: 10).order(order) # or reorder()
+
+scope.keyset_paginate.records # works
+```
+
+### Function-based ordering
+
+In the following example, we multiply the `id` by 10 and ordering by that value. Since the `id` column is unique, we need to define only one column:
+
+```ruby
+order = Gitlab::Pagination::Keyset::Order.build([
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'id_times_ten',
+ order_expression: Arel.sql('id * 10').asc,
+ nullable: :not_nullable,
+ order_direction: :asc,
+ distinct: true,
+ add_to_projections: true
+ )
+])
+
+paginator = Issue.where(project_id: 10).order(order).keyset_paginate(per_page: 5)
+puts paginator.records.map(&:id_times_ten)
+
+cursor = paginator.cursor_for_next_page
+
+paginator = Issue.where(project_id: 10).order(order).keyset_paginate(cursor: cursor, per_page: 5)
+puts paginator.records.map(&:id_times_ten)
+```
+
+The `add_to_projections` flag tells the paginator to expose the column expression in the `SELECT` clause. This is necessary because the keyset pagination needs to somehow extract the last value from the records to request the next page.
+
+### `iid` based ordering
+
+When ordering issues, the database ensures that we'll have distinct `iid` values within a project. Ordering by one column is enough to make the pagination work if the `project_id` filter is present:
+
+```ruby
+order = Gitlab::Pagination::Keyset::Order.build([
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'iid',
+ order_expression: Issue.arel_table[:iid].asc,
+ nullable: :not_nullable,
+ distinct: true
+ )
+])
+
+scope = Issue.where(project_id: 10).order(order)
+
+scope.keyset_paginate.records # works
+```
diff --git a/doc/development/database/pagination_guidelines.md b/doc/development/database/pagination_guidelines.md
index 3308ebfcaae..ce656851f86 100644
--- a/doc/development/database/pagination_guidelines.md
+++ b/doc/development/database/pagination_guidelines.md
@@ -58,9 +58,7 @@ It's not possible to make all filter and sort combinations performant, so we sho
### Prepare for scaling
-Offset-based pagination is the easiest way to paginate over records, however, it does not scale well for large tables. As a long-term solution, keyset pagination is preferred. The tooling around keyset pagination is not as mature as for offset pagination so currently, it's easier to start with offset pagination and then switch to keyset pagination.
-
-To avoid losing functionality and maintaining backward compatibility when switching pagination methods, it's advised to consider the following approach in the design phase:
+Offset-based pagination is the easiest way to paginate over records, however, it does not scale well for large database tables. As a long-term solution, [keyset pagination](keyset_pagination.md) is preferred. Switching between offset and keyset pagination is generally straightforward and can be done without affecting the end-user if the following conditions are met:
- Avoid presenting total counts, prefer limit counts.
- Example: count maximum 1001 records, and then on the UI show 1000+ if the count is 1001, show the actual number otherwise.
@@ -304,7 +302,22 @@ LIMIT 20
##### Tooling
-Using keyset pagination outside of GraphQL is not straightforward. We have the low-level blocks for building keyset pagination database queries, however, the usage in application code is still not streamlined yet.
+A generic keyset pagination library is available within the GitLab project which can most of the cases easly replace the existing, kaminari based pagination with significant performance improvements when dealing with large datasets.
+
+Example:
+
+```ruby
+# first page
+paginator = Project.order(:created_at, :id).keyset_paginate(per_page: 20)
+puts paginator.to_a # records
+
+# next page
+cursor = paginator.cursor_for_next_page
+paginator = Project.order(:created_at, :id).keyset_paginate(cursor: cursor, per_page: 20)
+puts paginator.to_a # records
+```
+
+For a comprehensive overview, take a look at the [keyset pagination guide](keyset_pagination.md) page.
#### Performance
diff --git a/doc/development/documentation/styleguide/index.md b/doc/development/documentation/styleguide/index.md
index 225db273cb6..7787366dbf4 100644
--- a/doc/development/documentation/styleguide/index.md
+++ b/doc/development/documentation/styleguide/index.md
@@ -216,15 +216,15 @@ to update.
Put files for a specific product area into the related folder:
-| Directory | What belongs here |
+| Directory | Contents |
|:----------------------|:------------------|
-| `doc/user/` | User related documentation. Anything that can be done in the GitLab user interface goes here, including usage of the `/admin` interface. |
+| `doc/user/` | Documentation for users. Anything that can be done in the GitLab user interface goes here, including usage of the `/admin` interface. |
| `doc/administration/` | Documentation that requires the user to have access to the server where GitLab is installed. Administrator settings in the GitLab user interface are under `doc/user/admin_area/`. |
-| `doc/api/` | API-related documentation. |
+| `doc/api/` | Documentation for the API. |
| `doc/development/` | Documentation related to the development of GitLab, whether contributing code or documentation. Related process and style guides should go here. |
| `doc/legal/` | Legal documents about contributing to GitLab. |
-| `doc/install/` | Contains instructions for installing GitLab. |
-| `doc/update/` | Contains instructions for updating GitLab. |
+| `doc/install/` | Instructions for installing GitLab. |
+| `doc/update/` | Instructions for updating GitLab. |
| `doc/topics/` | Indexes per topic (`doc/topics/topic_name/index.md`): all resources for that topic. |
### Work with directories and files
@@ -300,11 +300,17 @@ Do not include the same information in multiple places.
## Language
-GitLab documentation should be clear and easy to understand.
+GitLab documentation should be clear and easy to understand. Avoid unnecessary words.
-- Be clear, concise, and stick to the goal of the documentation.
+- Be clear, concise, and stick to the goal of the topic.
- Write in US English with US grammar. (Tested in [`British.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/.vale/gitlab/British.yml).)
- Use [inclusive language](#inclusive-language).
+- Rewrite to avoid wordiness:
+ - there is
+ - there are
+ - enables you to
+ - in order to
+ - because of the fact that
### Capitalization
diff --git a/doc/development/fe_guide/graphql.md b/doc/development/fe_guide/graphql.md
index 870605c82f4..844ef2156d9 100644
--- a/doc/development/fe_guide/graphql.md
+++ b/doc/development/fe_guide/graphql.md
@@ -842,6 +842,70 @@ Keep in mind, this means your app will not batch queries.
Once subscriptions are mature, this process can be replaced by using them and we can remove the separate link library and return to batching queries.
+#### Subscriptions
+
+We use [subscriptions](https://www.apollographql.com/docs/react/data/subscriptions/) to receive real-time updates from GraphQL API via websockets. Currently, the number of existing subscriptions is limited, you can check a list of available ones in [GraphqiQL explorer](https://gitlab.com/-/graphql-explorer)
+
+**NOTE:**
+We cannot test subscriptions using GraphiQL, because they require an ActionCable client, which GraphiQL does not support at the moment.
+
+Subscriptions don't require any additional configuration of Apollo Client instance, you can use them in the application right away. To distinguish subscriptions from queries and mutations, we recommend naming them with `.subscription.graphql` extension:
+
+```graphql
+// ~/sidebar/queries/issuable_assignees.subscription.graphql
+
+subscription issuableAssigneesUpdated($issuableId: IssuableID!) {
+ issuableAssigneesUpdated(issuableId: $issuableId) {
+ ... on Issue {
+ assignees {
+ nodes {
+ ...User
+ status {
+ availability
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+When using GraphQL subscriptions in Vue application, we recommend updating existing Apollo query results with [subscribeToMore](https://apollo.vuejs.org/guide/apollo/subscriptions.html#subscribe-to-more) option:
+
+```javascript
+import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql'
+
+apollo: {
+ issuable: {
+ query() {
+ return assigneesQueries[this.issuableType].query;
+ },
+ subscribeToMore: {
+ // Specify the subscription that will update the query
+ document() {
+ return issuableAssigneesSubscription;
+ },
+ variables() {
+ return {
+ issuableId: convertToGraphQLId(this.issuableClass, this.issuableId),
+ };
+ },
+ // Describe how subscription should update the query
+ updateQuery(prev, { subscriptionData }) {
+ if (prev && subscriptionData?.data?.issuableAssigneesUpdated) {
+ const data = produce(prev, (draftData) => {
+ draftData.workspace.issuable.assignees.nodes =
+ subscriptionData.data.issuableAssigneesUpdated.assignees.nodes;
+ });
+ return data;
+ }
+ return prev;
+ },
+ },
+ },
+},
+```
+
### Testing
#### Generating the GraphQL schema
diff --git a/doc/development/query_performance.md b/doc/development/query_performance.md
index 87e26cf42df..3ff36c7d005 100644
--- a/doc/development/query_performance.md
+++ b/doc/development/query_performance.md
@@ -38,8 +38,8 @@ cache, or what PostgreSQL calls shared buffers. This is the "warm cache" query.
When analyzing an [`EXPLAIN` plan](understanding_explain_plans.md), you can see
the difference not only in the timing, but by looking at the output for `Buffers`
-by running your explain with `EXPLAIN(analyze, buffers)`. The [#database-lab](understanding_explain_plans.md#database-lab)
-tool will automatically include these options.
+by running your explain with `EXPLAIN(analyze, buffers)`. [Database Lab](understanding_explain_plans.md#database-lab-engine)
+will automatically include these options.
If you are making a warm cache query, you will only see the `shared hits`.
diff --git a/doc/development/sidekiq_style_guide.md b/doc/development/sidekiq_style_guide.md
index c87870b088c..7bc3ecf002f 100644
--- a/doc/development/sidekiq_style_guide.md
+++ b/doc/development/sidekiq_style_guide.md
@@ -155,7 +155,7 @@ A job scheduled for an idempotent worker is [deduplicated](#deduplication) when
an unstarted job with the same arguments is already in the queue.
WARNING:
-For [data consistency jobs](#job-data-consistency), the deduplication is not compatible with the
+For [data consistency jobs](#job-data-consistency-strategies), the deduplication is not compatible with the
`data_consistency` attribute set to `:sticky` or `:delayed`.
The reason for this is that deduplication always takes into account the latest binary replication pointer into account, not the first one.
There is an [open issue](https://gitlab.com/gitlab-org/gitlab/-/issues/325291) to improve this.
@@ -462,18 +462,56 @@ If we expect an increase of **less than 5%**, then no further action is needed.
Otherwise, please ping `@gitlab-org/scalability` on the merge request and ask
for a review.
-## Job data consistency
+## Job data consistency strategies
-In order to utilize [Sidekiq read-only database replicas capabilities](../administration/database_load_balancing.md#enable-the-load-balancer-for-sidekiq),
-set the `data_consistency` attribute of the job to `:always`, `:sticky`, or `:delayed`.
+In GitLab 13.11 and earlier, Sidekiq workers would always send database queries to the primary
+database node,
+both for reads and writes. This ensured that data integrity
+is both guaranteed and immediate, since in a single-node scenario it is impossible to encounter
+stale reads even for workers that read their own writes.
+If a worker writes to the primary, but reads from a replica, however, the possibility
+of reading a stale record is non-zero due to replicas potentially lagging behind the primary.
+
+When the number of jobs that rely on the database increases, ensuring immediate data consistency
+can put unsustainable load on the primary database server. We therefore added the ability to use
+[database load-balancing in Sidekiq workers](../administration/database_load_balancing.md#enable-the-load-balancer-for-sidekiq).
+By configuring a worker's `data_consistency` field, we can then allow the scheduler to target read replicas
+under several strategies outlined below.
+
+## Trading immediacy for reduced primary load
+
+Not requiring immediate data consistency allows developers to decide to either:
+
+- Ensure immediately consistent reads, but increase load on the primary database.
+- Prefer read replicas to add relief to the primary, but increase the likelihood of stale reads that have to be retried.
+
+By default, any worker has a data consistency requirement of `:always`, so, as before, all
+database operations target the primary. To allow for reads to be served from replicas instead, we
+added two additional consistency modes: `:sticky` and `:delayed`.
+
+When you declare either `:sticky` or `:delayed` consistency, workers become eligible for database
+load-balancing. In both cases, jobs are enqueued with a short delay.
+This minimizes the likelihood of replication lag after a write.
+
+The difference is in what happens when there is replication lag after the delay: `sticky` workers
+switch over to the primary right away, whereas `delayed` workers fail fast and are retried once.
+If they still encounter replication lag, they also switch to the primary instead.
+**If your worker never performs any writes, it is strongly advised to apply one of these consistency settings,
+since it will never need to rely on the primary database node.**
+
+The table below shows the `data_consistency` attribute and its values, ordered by the degree to which
+they prefer read replicas and will wait for replicas to catch up:
| **Data Consistency** | **Description** |
|--------------|-----------------------------|
-| `:always` | The job is required to use the primary database (default). |
-| `:sticky` | The job uses a replica as long as possible. It switches to primary either on write or long replication lag. It should be used on jobs that require to be executed as fast as possible. |
-| `:delayed` | The job always uses replica, but switches to primary on write. The job is delayed if there's a long replication lag. If the replica is not up-to-date with the next retry, it switches to the primary. It should be used on jobs where we are fine to delay the execution of a given job due to their importance such as expire caches, execute hooks, etc. |
+| `:always` | The job is required to use the primary database (default). It should be used for workers that primarily perform writes or that have very strict requirements around reading their writes without suffering any form of delay. |
+| `:sticky` | The job prefers replicas, but switches to the primary for writes or when encountering replication lag. It should be used for jobs that require to be executed as fast as possible but can sustain a small initial queuing delay. |
+| `:delayed` | The job prefers replicas, but switches to the primary for writes. When encountering replication lag before the job starts, the job is retried once. If the replica is still not up to date on the next retry, it switches to the primary. It should be used for jobs where delaying execution further typically does not matter, such as cache expiration or web hooks execution. |
+
+In all cases workers read either from a replica that is fully caught up,
+or from the primary node, so data consistency is always ensured.
-To set a data consistency for a job, use the `data_consistency` class method:
+To set a data consistency for a worker, use the `data_consistency` class method:
```ruby
class DelayedWorker
@@ -499,8 +537,8 @@ When `feature_flag` is disabled, the job defaults to `:always`, which means that
The `feature_flag` property does not allow the use of
[feature gates based on actors](../development/feature_flags/index.md).
This means that the feature flag cannot be toggled only for particular
-projects, groups, or users, but instead, you can safely use [percentage of time rollout](../development/feature_flags/index.md).
-Note that since we check the feature flag on both Sidekiq client and server, rolling out a 10% of the time,
+projects, groups, or users, but instead, you can safely use [percentage of time rollout](../development/feature_flags/index.md).
+Note that since we check the feature flag on both Sidekiq client and server, rolling out a 10% of the time,
will likely results in 1% (0.1 [from client]*0.1 [from server]) of effective jobs using replicas.
Example:
@@ -515,15 +553,6 @@ class DelayedWorker
end
```
-### Delayed job execution
-
-Scheduling workers that utilize [Sidekiq read-only database replicas capabilities](#job-data-consistency),
-(workers with `data_consistency` attribute set to `:sticky` or `:delayed`),
-by calling `SomeWorker.perform_async` results in a worker performing in the future (1 second in the future).
-
-This way, the replica has a chance to catch up, and the job will likely use the replica.
-For workers with `data_consistency` set to `:delayed`, it can also reduce the number of retried jobs.
-
## Jobs with External Dependencies
Most background jobs in the GitLab application communicate with other GitLab
diff --git a/doc/development/understanding_explain_plans.md b/doc/development/understanding_explain_plans.md
index 66dc1fef31a..f9d1e7e2eee 100644
--- a/doc/development/understanding_explain_plans.md
+++ b/doc/development/understanding_explain_plans.md
@@ -198,13 +198,39 @@ Here we can see that our filter has to remove 65,677 rows, and that we use
208,846 buffers. Each buffer in PostgreSQL is 8 KB (8192 bytes), meaning our
above node uses *1.6 GB of buffers*. That's a lot!
+Keep in mind that some statistics are per-loop averages, while others are total values:
+
+| Field name | Value type |
+| --- | --- |
+| Actual Total Time | per-loop average |
+| Actual Rows | per-loop average |
+| Buffers Shared Hit | total value |
+| Buffers Shared Read | total value |
+| Buffers Shared Dirtied | total value |
+| Buffers Shared Written | total value |
+| I/O Read Time | total value |
+| I/O Read Write | total value |
+
+For example:
+
+```sql
+ -> Index Scan using users_pkey on public.users (cost=0.43..3.44 rows=1 width=1318) (actual time=0.025..0.025 rows=1 loops=888)
+ Index Cond: (users.id = issues.author_id)
+ Buffers: shared hit=3543 read=9
+ I/O Timings: read=17.760 write=0.000
+```
+
+Here we can see that this node used 3552 buffers (3543 + 9), returned 888 rows (`888 * 1`), and the actual duration was 22.2 milliseconds (`888 * 0.025`).
+17.76 milliseconds of the total duration was spent in reading from disk, to retrieve data that was not in the cache.
+
## Node types
There are quite a few different types of nodes, so we only cover some of the
more common ones here.
A full list of all the available nodes and their descriptions can be found in
-the [PostgreSQL source file `plannodes.h`](https://gitlab.com/postgres/postgres/blob/master/src/include/nodes/plannodes.h)
+the [PostgreSQL source file `plannodes.h`](https://gitlab.com/postgres/postgres/blob/master/src/include/nodes/plannodes.h).
+pgMustard's [EXPLAIN docs](https://www.pgmustard.com/docs/explain) also offer detailed look into nodes and their fields.
### Seq Scan
@@ -441,7 +467,7 @@ When optimizing a query, we usually need to reduce the amount of data we're
dealing with. Indexes are the way to work with fewer pages (buffers) to get the
result, so, during optimization, look at the number of buffers used (read and hit),
and work on reducing these numbers. Reduced timing will be the consequence of reduced
-buffer numbers. [#database-lab](#database-lab) guarantees that the plan is structurally
+buffer numbers. [Database Lab Engine](#database-lab-engine) guarantees that the plan is structurally
identical to production (and overall number of buffers is the same as on production),
but difference in cache state and I/O speed may lead to different timings.
@@ -617,7 +643,7 @@ If we look at the plan we also see our costs are very low:
Index Scan using projects_pkey on projects (cost=0.43..3.45 rows=1 width=4) (actual time=0.049..0.050 rows=1 loops=145)
```
-Here our cost is only 3.45, and it only takes us 0.050 milliseconds to do so.
+Here our cost is only 3.45, and it takes us 7.25 milliseconds to do so (0.05 * 145).
The next index scan is a bit more expensive:
```sql
@@ -681,64 +707,26 @@ There are a few ways to get the output of a query plan. Of course you
can directly run the `EXPLAIN` query in the `psql` console, or you can
follow one of the other options below.
-### Rails console
+### Database Lab Engine
-Using the [`activerecord-explain-analyze`](https://github.com/6/activerecord-explain-analyze)
-you can directly generate the query plan from the Rails console:
+GitLab team members can use [Database Lab Engine](https://gitlab.com/postgres-ai/database-lab), and the companion
+SQL optimization tool - [Joe Bot](https://gitlab.com/postgres-ai/joe).
-```ruby
-pry(main)> require 'activerecord-explain-analyze'
-=> true
-pry(main)> Project.where('build_timeout > ?', 3600).explain(analyze: true)
- Project Load (1.9ms) SELECT "projects".* FROM "projects" WHERE (build_timeout > 3600)
- ↳ (pry):12
-=> EXPLAIN for: SELECT "projects".* FROM "projects" WHERE (build_timeout > 3600)
-Seq Scan on public.projects (cost=0.00..2.17 rows=1 width=742) (actual time=0.040..0.041 rows=0 loops=1)
- Output: id, name, path, description, created_at, updated_at, creator_id, namespace_id, ...
- Filter: (projects.build_timeout > 3600)
- Rows Removed by Filter: 14
- Buffers: shared hit=2
-Planning time: 0.411 ms
-Execution time: 0.113 ms
-```
+Database Lab Engine provides developers with their own clone of the production database, while Joe Bot helps with exploring execution plans.
-### ChatOps
+Joe Bot is available in the [`#database-lab`](https://gitlab.slack.com/archives/CLJMDRD8C) channel on Slack,
+and through its [web interface](https://console.postgres.ai/gitlab/joe-instances).
-[GitLab team members can also use our ChatOps solution, available in Slack using the
-`/chatops` slash command](chatops_on_gitlabcom.md).
-You can use ChatOps to get a query plan by running the following:
+With Joe Bot you can execute DDL statements (like creating indexes, tables, and columns) and get query plans for `SELECT`, `UPDATE`, and `DELETE` statements.
-```sql
-/chatops run explain SELECT COUNT(*) FROM projects WHERE visibility_level IN (0, 20)
-```
+For example, in order to test new index on a column that is not existing on production yet, you can do the following:
-Visualising the plan using <https://explain.depesz.com/> is also supported:
+Create the column:
```sql
-/chatops run explain --visual SELECT COUNT(*) FROM projects WHERE visibility_level IN (0, 20)
+exec ALTER TABLE projects ADD COLUMN last_at timestamp without time zone
```
-Quoting the query is not necessary.
-
-For more information about the available options, run:
-
-```sql
-/chatops run explain --help
-```
-
-### `#database-lab`
-
-Another tool GitLab team members can use is a chatbot powered by [Joe](https://gitlab.com/postgres-ai/joe)
-which uses [Database Lab](https://gitlab.com/postgres-ai/database-lab) to instantly provide developers
-with their own clone of the production database.
-
-Joe is available in the
-[`#database-lab`](https://gitlab.slack.com/archives/CLJMDRD8C) channel on Slack.
-
-Unlike ChatOps, it gives you a way to execute DDL statements (like creating indexes and tables) and get query plan not only for `SELECT` but also `UPDATE` and `DELETE`.
-
-For example, in order to test new index you can do the following:
-
Create the index:
```sql
@@ -769,18 +757,67 @@ For more information about the available options, run:
help
```
+The web interface comes with the following execution plan visualizers included:
+
+- [Depesz](https://explain.depesz.com/)
+- [PEV2](https://github.com/dalibo/pev2)
+- [FlameGraph](https://github.com/mgartner/pg_flame)
+
#### Tips & Tricks
-The database connection is now maintained during your whole session, so you can use `exec set ...` for any session variables (such as `enable_seqscan` or `work_mem`). These settings will be applied to all subsequent commands until you reset them.
+The database connection is now maintained during your whole session, so you can use `exec set ...` for any session variables (such as `enable_seqscan` or `work_mem`). These settings will be applied to all subsequent commands until you reset them. For example you can disable parallel queries with
+
+```sql
+exec SET max_parallel_workers_per_gather = 0
+```
+
+### Rails console
+
+Using the [`activerecord-explain-analyze`](https://github.com/6/activerecord-explain-analyze)
+you can directly generate the query plan from the Rails console:
+
+```ruby
+pry(main)> require 'activerecord-explain-analyze'
+=> true
+pry(main)> Project.where('build_timeout > ?', 3600).explain(analyze: true)
+ Project Load (1.9ms) SELECT "projects".* FROM "projects" WHERE (build_timeout > 3600)
+ ↳ (pry):12
+=> EXPLAIN for: SELECT "projects".* FROM "projects" WHERE (build_timeout > 3600)
+Seq Scan on public.projects (cost=0.00..2.17 rows=1 width=742) (actual time=0.040..0.041 rows=0 loops=1)
+ Output: id, name, path, description, created_at, updated_at, creator_id, namespace_id, ...
+ Filter: (projects.build_timeout > 3600)
+ Rows Removed by Filter: 14
+ Buffers: shared hit=2
+Planning time: 0.411 ms
+Execution time: 0.113 ms
+```
+
+### ChatOps
+
+[GitLab team members can also use our ChatOps solution, available in Slack using the
+`/chatops` slash command](chatops_on_gitlabcom.md).
+
+NOTE:
+While ChatOps is still available, the recommended way to generate execution plans is to use [Database Lab Engine](#database-lab-engine).
-It is also possible to use transactions. This may be useful when you are working on statements that modify the data, for example INSERT, UPDATE, and DELETE. The `explain` command will perform `EXPLAIN ANALYZE`, which executes the statement. In order to run each `explain` starting from a clean state you can wrap it in a transaction, for example:
+You can use ChatOps to get a query plan by running the following:
```sql
-exec BEGIN
+/chatops run explain SELECT COUNT(*) FROM projects WHERE visibility_level IN (0, 20)
+```
-explain UPDATE some_table SET some_column = TRUE
+Visualising the plan using <https://explain.depesz.com/> is also supported:
+
+```sql
+/chatops run explain --visual SELECT COUNT(*) FROM projects WHERE visibility_level IN (0, 20)
+```
-exec ROLLBACK
+Quoting the query is not necessary.
+
+For more information about the available options, run:
+
+```sql
+/chatops run explain --help
```
## Further reading
diff --git a/doc/development/usage_ping/index.md b/doc/development/usage_ping/index.md
index 95dc4f2979a..de6a234e20c 100644
--- a/doc/development/usage_ping/index.md
+++ b/doc/development/usage_ping/index.md
@@ -24,11 +24,32 @@ More links:
## What is Usage Ping?
-- GitLab sends a weekly payload containing usage data to GitLab Inc. Usage Ping provides high-level data to help our product, support, and sales teams. It does not send any project names, usernames, or any other specific data. The information from the usage ping is not anonymous, it is linked to the hostname of the instance. Sending usage ping is optional, and any instance can disable analytics.
-- The usage data is primarily composed of row counts for different tables in the instance's database. By comparing these counts month over month (or week over week), we can get a rough sense for how an instance is using the different features in the product. In addition to counts, other facts
- that help us classify and understand GitLab installations are collected.
-- Usage ping is important to GitLab as we use it to calculate our Stage Monthly Active Users (SMAU) which helps us measure the success of our stages and features.
-- While usage ping is enabled, GitLab gathers data from the other instances and can show usage statistics of your instance to your users.
+Usage Ping is a process in GitLab that collects and sends a weekly payload to GitLab Inc.
+The payload provides important high-level data that helps our product, support,
+and sales teams understand how GitLab is used. For example, the data helps to:
+
+- Compare counts month over month (or week over week) to get a rough sense for how an instance uses
+ different product features.
+- Collect other facts that help us classify and understand GitLab installations.
+- Calculate our Stage Monthly Active Users (SMAU), which helps to measure the success of our stages
+ and features.
+
+Usage Ping information is not anonymous. It's linked to the instance's hostname. However, it does
+not contain project names, usernames, or any other specific data.
+
+Sending a Usage Ping payload is optional and can be [disabled](#disable-usage-ping) on any instance.
+When Usage Ping is enabled, GitLab gathers data from the other instances
+and can show your instance's usage statistics to your users.
+
+### Terminology
+
+We use the following terminology to describe the Usage Ping components:
+
+- **Usage Ping**: the process that collects and generates a JSON payload.
+- **Usage data**: the contents of the Usage Ping JSON payload. This includes metrics.
+- **Metrics**: primarily made up of row counts for different tables in an instance's database. Each
+ metric has a corresponding [metric definition](metrics_dictionary.md#metrics-definition-and-validation)
+ in a YAML file.
### Why should we enable Usage Ping?
diff --git a/doc/user/admin_area/analytics/usage_trends.md b/doc/user/admin_area/analytics/usage_trends.md
index 49c81b1a965..9c09b62f8af 100644
--- a/doc/user/admin_area/analytics/usage_trends.md
+++ b/doc/user/admin_area/analytics/usage_trends.md
@@ -17,7 +17,10 @@ This feature might not be available to you. Check the **version history** note a
Usage Trends gives you an overview of how much data your instance contains, and how quickly this volume is changing over time.
-To see Usage Trends, go to **Admin Area > Analytics > Usage Trends**.
+To see Usage Trends:
+
+1. On the top bar, select **Menu >** **{admin}** **Admin**.
+1. On the left sidebar, select **Analytics > Usage Trends**.
## Total counts
diff --git a/doc/user/admin_area/settings/email.md b/doc/user/admin_area/settings/email.md
index 5deb71698ff..e637408328d 100644
--- a/doc/user/admin_area/settings/email.md
+++ b/doc/user/admin_area/settings/email.md
@@ -20,8 +20,9 @@ The logo in the header of some emails can be customized, see the [logo customiza
The additional text appears at the bottom of any email and can be used for
legal/auditing/compliance reasons.
-1. Go to **Admin Area > Settings > Preferences** (`/admin/application_settings/preferences`).
-1. Expand the **Email** section.
+1. On the top bar, select **Menu >** **{admin}** **Admin**.
+1. On the left sidebar, select **Settings > Preferences** (`/admin/application_settings/preferences`).
+1. Expand **Email**.
1. Enter your text in the **Additional text** field.
1. Click **Save**.
@@ -34,8 +35,9 @@ This configuration option sets the email hostname for [private commit emails](..
In order to change this option:
-1. Go to **Admin Area > Settings > Preferences** (`/admin/application_settings/preferences`).
-1. Expand the **Email** section.
+1. On the top bar, select **Menu >** **{admin}** **Admin**.
+1. On the left sidebar, select **Settings > Preferences** (`/admin/application_settings/preferences`).
+1. Expand **Email**.
1. Enter the desired hostname in the **Custom hostname (for private commit emails)** field.
1. Select **Save changes**.
diff --git a/doc/user/admin_area/settings/img/file_template_admin_area.png b/doc/user/admin_area/settings/img/file_template_admin_area.png
deleted file mode 100644
index 269d997e1d9..00000000000
--- a/doc/user/admin_area/settings/img/file_template_admin_area.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/admin_area/settings/img/file_template_admin_area_v14_0.png b/doc/user/admin_area/settings/img/file_template_admin_area_v14_0.png
new file mode 100644
index 00000000000..33fce8a2b77
--- /dev/null
+++ b/doc/user/admin_area/settings/img/file_template_admin_area_v14_0.png
Binary files differ
diff --git a/doc/user/admin_area/settings/instance_template_repository.md b/doc/user/admin_area/settings/instance_template_repository.md
index c8a4c2866ca..8a796435ef8 100644
--- a/doc/user/admin_area/settings/instance_template_repository.md
+++ b/doc/user/admin_area/settings/instance_template_repository.md
@@ -23,7 +23,7 @@ To select a project to serve as the custom template repository:
1. In the left sidebar, select **Settings > Templates**.
1. Select the project:
- ![File templates in the Admin Area](img/file_template_admin_area.png)
+ ![File templates in the Admin Area](img/file_template_admin_area_v14_0.png)
1. Add custom templates to the selected repository.
diff --git a/doc/user/admin_area/settings/rate_limit_on_issues_creation.md b/doc/user/admin_area/settings/rate_limit_on_issues_creation.md
index 3acfb636a13..ef2b8ad80cd 100644
--- a/doc/user/admin_area/settings/rate_limit_on_issues_creation.md
+++ b/doc/user/admin_area/settings/rate_limit_on_issues_creation.md
@@ -10,7 +10,13 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/28129) in GitLab 12.10.
This setting allows you to rate limit the requests to the issue creation endpoint.
-You can change its value in **Admin Area > Settings > Network > Issues Rate Limits**.
+To can change its value:
+
+1. On the top bar, select **Menu >** **{admin}** **Admin**.
+1. On the left sidebar, select **Settings > Network**.
+1. Expand **Issues Rate Limits**.
+1. Under **Max requests per minute per user**, enter the new value.
+1. Select **Save changes**.
For example, if you set a limit of 300, requests using the
[Projects::IssuesController#create](https://gitlab.com/gitlab-org/gitlab/raw/master/app/controllers/projects/issues_controller.rb)
diff --git a/doc/user/admin_area/settings/rate_limit_on_notes_creation.md b/doc/user/admin_area/settings/rate_limit_on_notes_creation.md
index 67a97d26b34..193f39542cf 100644
--- a/doc/user/admin_area/settings/rate_limit_on_notes_creation.md
+++ b/doc/user/admin_area/settings/rate_limit_on_notes_creation.md
@@ -13,9 +13,11 @@ This setting allows you to rate limit the requests to the note creation endpoint
To change the note creation rate limit:
-1. Go to **Admin Area > Settings > Network**.
-1. Expand the **Notes Rate Limits** section.
-1. Enter the new value.
+1. On the top bar, select **Menu >** **{admin}** **Admin**.
+1. On the left sidebar, select **Settings > Network**.
+1. Expand **Notes Rate Limits**.
+1. Under **Max requests per minute per user**, enter the new value.
+1. Optional. Under **List of users to be excluded from the limit**, list users to be excluded fromt the limit.
1. Select **Save changes**.
This limit is:
diff --git a/doc/user/application_security/container_scanning/index.md b/doc/user/application_security/container_scanning/index.md
index 8e83ade5608..323a064c3e4 100644
--- a/doc/user/application_security/container_scanning/index.md
+++ b/doc/user/application_security/container_scanning/index.md
@@ -11,10 +11,11 @@ info: To determine the technical writer assigned to the Stage/Group associated w
WARNING:
Versions of GitLab prior to 14.0 used Clair as the default container scanning engine. GitLab 14.0
-replaces Clair with Trivy and removes Clair from the product. If you run container scanning with the
-default settings, GitLab switches you seamlessly and automatically to Trivy in GitLab 14.0. However,
-if you customized the variables in your container scanning job, you should review the
-[migration guide](#migrating-from-clair-to-trivy) and make any necessary updates.
+removes Clair from the product and replaces it with two new scanners. If you
+run container scanning with the default settings, GitLab switches you seamlessly and automatically
+to Trivy in GitLab 14.0. However, if you customized the variables in your container scanning job,
+you should review the [migration guide](#change-scanners)
+and make any necessary updates.
Your application's Docker image may itself be based on Docker images that contain known
vulnerabilities. By including an extra job in your pipeline that scans for those vulnerabilities and
@@ -23,6 +24,7 @@ displays them in a merge request, you can use GitLab to audit your Docker-based
GitLab provides integration with open-source tools for vulnerability static analysis in containers:
- [Trivy](https://github.com/aquasecurity/trivy)
+- [Grype](https://github.com/anchore/grype)
To integrate GitLab with security scanners other than those listed here, see
[Security scanner integration](../../../development/integrations/secure.md).
@@ -79,8 +81,10 @@ Other changes:
- GitLab 13.9 [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/322656) integration with
[Trivy](https://github.com/aquasecurity/trivy) by upgrading `CS_MAJOR_VERSION` from `3` to `4`.
- GitLab 14.0 [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61850)
- integration with [Trivy](https://github.com/aquasecurity/trivy)
- as the default for container scanning.
+ an integration with [Trivy](https://github.com/aquasecurity/trivy)
+ as the default for container scanning, and also [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/326279)
+ an integration with [Grype](https://github.com/anchore/grype)
+ as an alternative scanner.
To include the `Container-Scanning.gitlab-ci.yml` template (GitLab 11.9 and later), add the
following to your `.gitlab-ci.yml` file:
@@ -151,7 +155,7 @@ You can [configure](#customizing-the-container-scanning-settings) analyzers by u
| `ADDITIONAL_CA_CERT_BUNDLE` | `""` | Bundle of CA certs that you want to trust. See [Using a custom SSL CA certificate authority](#using-a-custom-ssl-ca-certificate-authority) for more details. | All |
| `CI_APPLICATION_REPOSITORY` | `$CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG` | Docker repository URL for the image to be scanned. | All |
| `CI_APPLICATION_TAG` | `$CI_COMMIT_SHA` | Docker repository tag for the image to be scanned. | All |
-| `CS_ANALYZER_IMAGE` | `$SECURE_ANALYZERS_PREFIX/$CS_PROJECT:$CS_MAJOR_VERSION` | Docker image of the analyzer. | All |
+| `CS_ANALYZER_IMAGE` | `registry.gitlab.com/security-products/container-scanning:4` | Docker image of the analyzer. | All |
| `CS_DOCKER_INSECURE` | `"false"` | Allow access to secure Docker registries using HTTPS without validating the certificates. | All |
| `CS_REGISTRY_INSECURE` | `"false"` | Allow access to insecure registries (HTTP only). Should only be set to `true` when testing the image locally. | Trivy. The registry must listen on port `80/tcp`. |
| `CS_SEVERITY_THRESHOLD` | `UNKNOWN` | Severity level threshold. The scanner outputs vulnerabilities with severity level higher than or equal to this threshold. Supported levels are Unknown, Low, Medium, High, and Critical. | Trivy |
@@ -165,6 +169,7 @@ You can [configure](#customizing-the-container-scanning-settings) analyzers by u
Support depends on the scanner:
+- [Grype](https://github.com/anchore/grype#grype)
- [Trivy](https://aquasecurity.github.io/trivy/latest/vuln-detection/os/) (Default).
### Overriding the container scanning template
@@ -189,7 +194,18 @@ GitLab 13.0 and later doesn't support [`only` and `except`](../../../ci/yaml/REA
When overriding the template, you must use [`rules`](../../../ci/yaml/README.md#rules)
instead.
-### Migrating from Clair to Trivy
+### Change scanners
+
+The container-scanning analyzer can use different scanners, depending on the value of the
+`CS_ANALYZER_IMAGE` configuration variable.
+
+The following options are available:
+
+| Scanner name | `CS_ANALYZER_IMAGE` |
+| ------------ | ------------------- |
+| Default ([Trivy](https://github.com/aquasecurity/trivy)) | `registry.gitlab.com/security-products/container-scanning:4` |
+| [Grype](https://github.com/anchore/grype) | `registry.gitlab.com/security-products/container-scanning/grype:4` |
+| Trivy | `registry.gitlab.com/security-products/container-scanning/trivy:4` |
If you're migrating from a GitLab 13.x release to a GitLab 14.x release and have customized the
`container_scanning` job or its CI variables, you might need to perform these migration steps in
@@ -214,17 +230,16 @@ your CI file:
complete list of supported variables, see [available variables](#available-cicd-variables).
1. Make any [necessary customizations](#customizing-the-container-scanning-settings)
- to the `Trivy` scanner. We recommend that you minimize such customizations, as they might require
+ to the chosen scanner. We recommend that you minimize such customizations, as they might require
changes in future GitLab major releases.
1. Trigger a new run of a pipeline that includes the `container_scanning` job. Inspect the job
output and ensure that the log messages do not mention Clair.
-**Troubleshooting**
-
+NOTE:
Prior to the GitLab 14.0 release, any variable defined under the scope `container_scanning` is not
-considered for the Trivy scanner. Verify that all variables for Trivy are
-either defined as a global variable, or under `container_scanning`.
+considered for scanners other than Clair. In GitLab 14.0 and later, all variables can be defined
+either as a global variable or under `container_scanning`.
### Using a custom SSL CA certificate authority
@@ -362,14 +377,17 @@ Support for custom certificate authorities was introduced in the following versi
| Scanner | Version |
| -------- | ------- |
| `Trivy` | [4.0.0](https://gitlab.com/gitlab-org/security-products/analyzers/container-scanning/-/releases/4.0.0) |
+| `Grype` | [4.3.0](https://gitlab.com/gitlab-org/security-products/analyzers/container-scanning/-/releases/4.3.0) |
#### Make GitLab container scanning analyzer images available inside your Docker registry
-For container scanning, import the following default images from `registry.gitlab.com` into your
+For container scanning, import the following images from `registry.gitlab.com` into your
[local Docker container registry](../../packages/container_registry/index.md):
```plaintext
-registry.gitlab.com/security-products/container-scanning
+registry.gitlab.com/security-products/container-scanning:4
+registry.gitlab.com/security-products/container-scanning/grype:4
+registry.gitlab.com/security-products/container-scanning/trivy:4
```
The process for importing Docker images into a local offline Docker registry depends on
@@ -410,13 +428,13 @@ following `.gitlab-yml.ci` example as a template.
```yaml
variables:
SOURCE_IMAGE: registry.gitlab.com/security-products/container-scanning:4
- TARGET_IMAGE: $CI_REGISTRY/$CI_PROJECT_PATH/gitlab-container-scanning
+ TARGET_IMAGE: $CI_REGISTRY/namespace/gitlab-container-scanning
image: docker:stable
update-scanner-image:
services:
- - docker:19-dind
+ - docker:dind
script:
- docker pull $SOURCE_IMAGE
- docker tag $SOURCE_IMAGE $TARGET_IMAGE
diff --git a/doc/user/application_security/sast/index.md b/doc/user/application_security/sast/index.md
index c11e367a688..e80807b31bf 100644
--- a/doc/user/application_security/sast/index.md
+++ b/doc/user/application_security/sast/index.md
@@ -510,8 +510,8 @@ Some analyzers can be customized with CI/CD variables.
| `SBT_PATH` | SpotBugs | Path to the `sbt` executable. |
| `FAIL_NEVER` | SpotBugs | Set to `1` to ignore compilation failure. |
| `SAST_GOSEC_CONFIG` | Gosec | **{warning}** **[Removed](https://gitlab.com/gitlab-org/gitlab/-/issues/328301)** in GitLab 14.0 - use custom rulesets instead. Path to configuration for Gosec (optional). |
-| `PHPCS_SECURITY_AUDIT_PHP_EXTENSIONS` | phpcs-security-audit | Comma separated list of additional PHP Extensions. |
-| `SAST_DISABLE_BABEL` | NodeJsScan | Disable Babel processing for the NodeJsScan scanner. Set to `true` to disable Babel processing. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/33065) in GitLab 13.2.
+| `PHPCS_SECURITY_AUDIT_PHP_EXTENSIONS` | phpcs-security-audit | Comma separated list of additional PHP Extensions. |
+| `SAST_DISABLE_BABEL` | NodeJsScan | **{warning}** **[Removed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64025)** in GitLab 13.5 |
| `SAST_SEMGREP_METRICS` | Semgrep | Set to `"false"` to disable sending anonymized scan metrics to [r2c](https://r2c.dev/). Default: `true`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/330565) in GitLab 14.0. |
#### Custom CI/CD variables
diff --git a/doc/user/gitlab_com/index.md b/doc/user/gitlab_com/index.md
index 223d3363186..f371de30b88 100644
--- a/doc/user/gitlab_com/index.md
+++ b/doc/user/gitlab_com/index.md
@@ -11,15 +11,15 @@ This page contains information about the settings that are used on
## SSH host keys fingerprints
-Below are the fingerprints for GitLab.com's SSH host keys. The first time you connect
-to a GitLab.com repository, one of these keys is displayed in the output.
+Below are the fingerprints for GitLab.com's SSH host keys. The first time you
+connect to a GitLab.com repository, one of these keys is displayed in the output.
-| Algorithm | MD5 (deprecated) | SHA256 |
-| --------- | --- | ------- |
-| DSA (deprecated) | `7a:47:81:3a:ee:89:89:64:33:ca:44:52:3d:30:d4:87` | `p8vZBUOR0XQz6sYiaWSMLmh0t9i8srqYKool/Xfdfqw` |
-| ECDSA | `f1:d0:fb:46:73:7a:70:92:5a:ab:5d:ef:43:e2:1c:35` | `HbW3g8zUjNSksFbqTiUWPWg2Bq1x8xdGUrliXFzSnUw` |
-| ED25519 | `2e:65:6a:c8:cf:bf:b2:8b:9a:bd:6d:9f:11:5c:12:16` | `eUXGGm1YGsMAS7vkcx6JOJdOGHPem5gQp4taiCfCLB8` |
-| RSA | `b6:03:0e:39:97:9e:d0:e7:24:ce:a3:77:3e:01:42:09` | `ROQFvPThGrW4RuWLoL9tq9I9zJ42fK4XywyRtbOz/EQ` |
+| Algorithm | MD5 (deprecated) | SHA256 |
+|------------------|------------------|---------|
+| ED25519 | `2e:65:6a:c8:cf:bf:b2:8b:9a:bd:6d:9f:11:5c:12:16` | `eUXGGm1YGsMAS7vkcx6JOJdOGHPem5gQp4taiCfCLB8` |
+| RSA | `b6:03:0e:39:97:9e:d0:e7:24:ce:a3:77:3e:01:42:09` | `ROQFvPThGrW4RuWLoL9tq9I9zJ42fK4XywyRtbOz/EQ` |
+| DSA (deprecated) | `7a:47:81:3a:ee:89:89:64:33:ca:44:52:3d:30:d4:87` | `p8vZBUOR0XQz6sYiaWSMLmh0t9i8srqYKool/Xfdfqw` |
+| ECDSA | `f1:d0:fb:46:73:7a:70:92:5a:ab:5d:ef:43:e2:1c:35` | `HbW3g8zUjNSksFbqTiUWPWg2Bq1x8xdGUrliXFzSnUw` |
## SSH `known_hosts` entries
@@ -34,32 +34,40 @@ gitlab.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAA
## Mail configuration
-GitLab.com sends emails from the `mg.gitlab.com` domain via [Mailgun](https://www.mailgun.com/) and has
-its own dedicated IP address (`192.237.158.143`).
+GitLab.com sends emails from the `mg.gitlab.com` domain by using [Mailgun](https://www.mailgun.com/),
+and has its own dedicated IP address (`192.237.158.143`).
-NOTE:
The IP address for `mg.gitlab.com` is subject to change at any time.
## Backups
[See our backup strategy](https://about.gitlab.com/handbook/engineering/infrastructure/production/#backups).
-There are several ways to perform backups of your content on GitLab.com.
+To back up an entire project on GitLab.com, you can export it either:
-Projects can be backed up in their entirety by exporting them either [through the UI](../project/settings/import_export.md) or [API](../../api/project_import_export.md#schedule-an-export), the latter of which can be used to programmatically upload exports to a storage platform such as AWS S3.
+- [Through the UI](../project/settings/import_export.md).
+- [Through the API](../../api/project_import_export.md#schedule-an-export). You
+ can also use the API to programmatically upload exports to a storage platform,
+ such as Amazon S3.
-With exports, be sure to take note of [what is and is not](../project/settings/import_export.md#exported-contents), included in a project export.
+With exports, be aware of [what is and is not](../project/settings/import_export.md#exported-contents)
+included in a project export.
-Since GitLab is built on Git, you can back up **just** the repository of a project by [cloning](../../gitlab-basics/start-using-git.md#clone-a-repository) it to another machine. Similarly, if you need to back up just the wiki of a repository it can also be cloned and all files uploaded to that wiki are included [if they were uploaded after 2020-08-22](../project/wiki/index.md#create-a-new-wiki-page).
+GitLab is built on Git, so you can back up just the repository of a project by
+[cloning](../../gitlab-basics/start-using-git.md#clone-a-repository) it to
+another computer.
+Similarly, you can clone a project's wiki to back it up. All files
+[uploaded after August 22, 2020](../project/wiki/index.md#create-a-new-wiki-page)
+are included when cloning.
## Alternative SSH port
-GitLab.com can be reached via a [different SSH port](https://about.gitlab.com/blog/2016/02/18/gitlab-dot-com-now-supports-an-alternate-git-plus-ssh-port/) for `git+ssh`.
+GitLab.com can be reached by using a [different SSH port](https://about.gitlab.com/blog/2016/02/18/gitlab-dot-com-now-supports-an-alternate-git-plus-ssh-port/) for `git+ssh`.
-| Setting | Value |
-| --------- | ------------------- |
-| `Hostname` | `altssh.gitlab.com` |
-| `Port` | `443` |
+| Setting | Value |
+|------------|---------------------|
+| `Hostname` | `altssh.gitlab.com` |
+| `Port` | `443` |
An example `~/.ssh/config` is the following:
@@ -76,26 +84,26 @@ Host gitlab.com
Below are the settings for [GitLab Pages](https://about.gitlab.com/stages-devops-lifecycle/pages/).
-| Setting | GitLab.com | Default |
-| --------------------------- | ---------------- | ------------- |
-| Domain name | `gitlab.io` | - |
-| IP address | `35.185.44.232` | - |
-| Custom domains support | yes | no |
-| TLS certificates support | yes | no |
-| Maximum size (compressed) | 1G | 100M |
+| Setting | GitLab.com | Default |
+|---------------------------|------------------------|------------------------|
+| Domain name | `gitlab.io` | - |
+| IP address | `35.185.44.232` | - |
+| Custom domains support | **{check-circle}** Yes | **{dotted-circle}** No |
+| TLS certificates support | **{check-circle}** Yes | **{dotted-circle}** No |
+| Maximum size (compressed) | 1 GB | 100 MB |
-NOTE:
-The maximum size of your Pages site is regulated by the artifacts maximum size
+The maximum size of your Pages site is regulated by the artifacts maximum size,
which is part of [GitLab CI/CD](#gitlab-cicd).
## GitLab CI/CD
Below are the current settings regarding [GitLab CI/CD](../../ci/README.md).
-Any settings or feature limits not listed here are using the defaults listed in the related documentation.
+Any settings or feature limits not listed here are using the defaults listed in
+the related documentation.
-| Setting | GitLab.com | Default |
-| ----------- | ----------------- | ------------- |
-| Artifacts maximum size (compressed) | 1G | 100M |
+| Setting | GitLab.com | Default |
+|-------------------------------------|------------|---------|
+| Artifacts maximum size (compressed) | 1 GB | 100 MB |
| Artifacts [expiry time](../../ci/yaml/README.md#artifactsexpire_in) | From June 22, 2020, deleted after 30 days unless otherwise specified (artifacts created before that date have no expiry). | deleted after 30 days unless otherwise specified |
| Scheduled Pipeline Cron | `*/5 * * * *` | `3-59/10 * * * *` |
| [Max jobs in active pipelines](../../administration/instance_limits.md#number-of-jobs-in-active-pipelines) | `500` for Free tier, unlimited otherwise | Unlimited |
@@ -107,19 +115,22 @@ Any settings or feature limits not listed here are using the defaults listed in
## Account and limit settings
-GitLab.com has the following [account limits](../admin_area/settings/account_and_limit_settings.md) enabled. If a setting is not listed, it is set to the default value.
+GitLab.com has the following [account limits](../admin_area/settings/account_and_limit_settings.md)
+enabled. If a setting is not listed, it is set to the default value.
-If you are near
-or over the repository size limit, you can [reduce your repository size with Git](../project/repository/reducing_the_repo_size_using_git.md).
+If you are near or over the repository size limit, you can
+[reduce your repository size with Git](../project/repository/reducing_the_repo_size_using_git.md).
-| Setting | GitLab.com | Default |
-| ----------- | ----------- | ------------- |
+| Setting | GitLab.com | Default |
+|-------------------------------|------------|---------|
| [Repository size including LFS](../admin_area/settings/account_and_limit_settings.md#repository-size-limit) | 10 GB | Unlimited |
-| Maximum import size | 5 GB | Unlimited ([Modified](https://gitlab.com/gitlab-org/gitlab/-/issues/251106) from 50MB to unlimited in GitLab 13.8. |
-| Maximum attachment size | 10 MB | 10 MB |
+| Maximum import size | 5 GB | Unlimited ([Modified](https://gitlab.com/gitlab-org/gitlab/-/issues/251106) from 50MB to unlimited in GitLab 13.8. |
+| Maximum attachment size | 10 MB | 10 MB |
NOTE:
-`git push` and GitLab project imports are limited to 5 GB per request through Cloudflare. Git LFS and imports other than a file upload are not affected by this limit.
+`git push` and GitLab project imports are limited to 5 GB per request through
+Cloudflare. Git LFS and imports other than a file upload are not affected by
+this limit.
## IP range
@@ -129,17 +140,16 @@ from those IPs and allow them.
GitLab.com is fronted by Cloudflare. For incoming connections to GitLab.com you might need to allow CIDR blocks of Cloudflare ([IPv4](https://www.cloudflare.com/ips-v4) and [IPv6](https://www.cloudflare.com/ips-v6)).
-For outgoing connections from CI/CD runners we are not providing static IP addresses.
-All our runners are deployed into Google Cloud Platform (GCP) - any IP based
-firewall can be configured by looking up all
+For outgoing connections from CI/CD runners, we are not providing static IP
+addresses. All GitLab runners are deployed into Google Cloud Platform (GCP). Any
+IP-based firewall can be configured by looking up all
[IP address ranges or CIDR blocks for GCP](https://cloud.google.com/compute/docs/faq#find_ip_range).
## Hostname list
-To configure allow-lists in local HTTP(S) proxies, or other
-web-blocking software that govern end-user machines,
-pages on GitLab.com will attempt to load content from
-the following hostnames:
+Add these hostnames when you configure allow-lists in local HTTP(S) proxies,
+or other web-blocking software that governs end-user computers. Pages on
+GitLab.com load content from these hostnames:
- `gitlab.com`
- `*.gitlab.com`
@@ -147,19 +157,18 @@ the following hostnames:
- `*.gitlab.io`
- `*.gitlab.net`
-Documentation and Company pages served over `docs.gitlab.com`
-and `about.gitlab.com` will attempt to also load certain page
-content directly from common public CDN hostnames.
+Documentation and Company pages served over `docs.gitlab.com` and `about.gitlab.com`
+also load certain page content directly from common public CDN hostnames.
## Webhooks
The following limits apply for [Webhooks](../project/integrations/webhooks.md):
-| Setting | GitLab.com | Default |
-| ------- | ---------- | ------- |
-| [Webhook rate limit](../../administration/instance_limits.md#webhook-rate-limit) | `120` calls per minute for Free tier, unlimited for all paid tiers | Unlimited
-| [Number of webhooks](../../administration/instance_limits.md#number-of-webhooks) | `100` per-project, `50` per-group | `100` per-project, `50` per-group
-| Maximum payload size | `25 MB` | `25 MB`
+| Setting | GitLab.com | Default |
+|----------------------|------------|---------|
+| [Webhook rate limit](../../administration/instance_limits.md#webhook-rate-limit) | `120` calls per minute for GitLab Free, unlimited for GitLab Premium and GitLab Ultimate | Unlimited |
+| [Number of webhooks](../../administration/instance_limits.md#number-of-webhooks) | `100` per project, `50` per group | `100` per project, `50` per group |
+| Maximum payload size | 25 MB | 25 MB |
## Shared runners
@@ -172,15 +181,15 @@ For more information, see [choosing a runner](../../ci/runners/README.md).
GitLab.com runs [Sidekiq](https://sidekiq.org) with arguments `--timeout=4 --concurrency=4`
and the following environment variables:
-| Setting | GitLab.com | Default |
-|-------- |----------- |-------- |
-| `SIDEKIQ_DAEMON_MEMORY_KILLER` | - | `1` |
-| `SIDEKIQ_MEMORY_KILLER_MAX_RSS` | `2000000` | `2000000` |
-| `SIDEKIQ_MEMORY_KILLER_HARD_LIMIT_RSS` | - | - |
-| `SIDEKIQ_MEMORY_KILLER_CHECK_INTERVAL` | - | `3` |
-| `SIDEKIQ_MEMORY_KILLER_GRACE_TIME` | - | `900` |
-| `SIDEKIQ_MEMORY_KILLER_SHUTDOWN_WAIT` | - | `30` |
-| `SIDEKIQ_LOG_ARGUMENTS` | `1` | `1` |
+| Setting | GitLab.com | Default |
+|----------------------------------------|------------|-----------|
+| `SIDEKIQ_DAEMON_MEMORY_KILLER` | - | `1` |
+| `SIDEKIQ_MEMORY_KILLER_MAX_RSS` | `2000000` | `2000000` |
+| `SIDEKIQ_MEMORY_KILLER_HARD_LIMIT_RSS` | - | - |
+| `SIDEKIQ_MEMORY_KILLER_CHECK_INTERVAL` | - | `3` |
+| `SIDEKIQ_MEMORY_KILLER_GRACE_TIME` | - | `900` |
+| `SIDEKIQ_MEMORY_KILLER_SHUTDOWN_WAIT` | - | `30` |
+| `SIDEKIQ_LOG_ARGUMENTS` | `1` | `1` |
NOTE:
The `SIDEKIQ_MEMORY_KILLER_MAX_RSS` setting is `16000000` on Sidekiq import
@@ -228,11 +237,8 @@ The list of GitLab.com specific settings (and their defaults) is as follows:
| `idle_in_transaction_session_timeout` | 60s | 60s |
Some of these settings are in the process being adjusted. For example, the value
-for `shared_buffers` is quite high and as such we are looking into adjusting it.
-More information on this particular change can be found at
-<https://gitlab.com/gitlab-com/infrastructure/-/issues/1555>. An up to date list
-of proposed changes can be found at
-<https://gitlab.com/gitlab-com/infrastructure/-/issues?scope=all&state=opened&label_name[]=database&label_name[]=change>.
+for `shared_buffers` is quite high, and we are
+[considering adjusting it](https://gitlab.com/gitlab-com/gl-infra/infrastructure/-/issues/4985).
## Puma
@@ -282,16 +288,18 @@ paths that exceed 10 requests per **minute** per IP address.
See the source below for which paths are protected. This includes user creation,
user confirmation, user sign in, and password reset.
-[User and IP rate limits](../admin_area/settings/user_and_ip_rate_limits.md#response-headers) includes a list of the headers responded to blocked requests.
+[User and IP rate limits](../admin_area/settings/user_and_ip_rate_limits.md#response-headers)
+includes a list of the headers responded to blocked requests.
See [Protected Paths](../admin_area/settings/protected_paths.md) for more details.
### IP blocks
IP blocks can occur when GitLab.com receives unusual traffic from a single
-IP address that the system views as potentially malicious, based on rate limit
-settings. After the unusual traffic ceases, the IP address is automatically
-released depending on the type of block, as described in a following section.
+IP address that the system views as potentially malicious. This can be based on
+rate limit settings. After the unusual traffic ceases, the IP address is
+automatically released depending on the type of block, as described in a
+following section.
If you receive a `403 Forbidden` error for all requests to GitLab.com,
check for any automated processes that may be triggering a block. For
@@ -309,8 +317,8 @@ This applies only to Git requests and container registry (`/jwt/auth`) requests
This limit:
- Is reset by requests that authenticate successfully. For example, 29
- failed authentication requests followed by 1 successful request, followed by 29
- more failed authentication requests would not trigger a ban.
+ failed authentication requests followed by 1 successful request, followed by
+ 29 more failed authentication requests would not trigger a ban.
- Does not apply to JWT requests authenticated by `gitlab-ci-token`.
No response headers are provided.
@@ -326,33 +334,42 @@ doesn't return the following headers:
### Visibility settings
-On GitLab.com, projects, groups, and snippets created
-As of GitLab 12.2 (July 2019), projects, groups, and snippets have the
-[**Internal** visibility](../../public_access/public_access.md#internal-projects) setting [disabled on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/issues/12388).
+If created before GitLab 12.2 (July 2019), these items have the
+[Internal visibility](../../public_access/public_access.md#internal-projects)
+setting [disabled on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/issues/12388):
+
+- Projects
+- Groups
+- Snippets
### SSH maximum number of connections
-GitLab.com defines the maximum number of concurrent, unauthenticated SSH connections by
-using the [MaxStartups setting](http://man.openbsd.org/sshd_config.5#MaxStartups).
-If more than the maximum number of allowed connections occur concurrently, they are
-dropped and users get
+GitLab.com defines the maximum number of concurrent, unauthenticated SSH
+connections by using the [MaxStartups setting](http://man.openbsd.org/sshd_config.5#MaxStartups).
+If more than the maximum number of allowed connections occur concurrently, they
+are dropped and users get
[an `ssh_exchange_identification` error](../../topics/git/troubleshooting_git.md#ssh_exchange_identification-error).
### Import/export
-To help avoid abuse, project and group imports, exports, and export downloads are rate limited. See [Project import/export rate limits](../../user/project/settings/import_export.md#rate-limits) and [Group import/export rate limits](../../user/group/settings/import_export.md#rate-limits) for details.
+To help avoid abuse, project and group imports, exports, and export downloads
+are rate limited. See [Project import/export rate limits](../../user/project/settings/import_export.md#rate-limits) and [Group import/export rate limits](../../user/group/settings/import_export.md#rate-limits)
+for details.
### Non-configurable limits
-See [non-configurable limits](../../security/rate_limits.md#non-configurable-limits) for information on
-rate limits that are not configurable, and therefore also used on GitLab.com.
+See [non-configurable limits](../../security/rate_limits.md#non-configurable-limits)
+for information on rate limits that are not configurable, and therefore also
+used on GitLab.com.
## GitLab.com Logging
-We use [Fluentd](https://gitlab.com/gitlab-com/runbooks/tree/master/logging/doc#fluentd) to parse our logs. Fluentd sends our logs to
-[Stackdriver Logging](https://gitlab.com/gitlab-com/runbooks/tree/master/logging/doc#stackdriver) and [Cloud Pub/Sub](https://gitlab.com/gitlab-com/runbooks/tree/master/logging/doc#cloud-pubsub).
-Stackdriver is used for storing logs long-term in Google Cold Storage (GCS). Cloud Pub/Sub
-is used to forward logs to an [Elastic cluster](https://gitlab.com/gitlab-com/runbooks/tree/master/logging/doc#elastic) using [`pubsubbeat`](https://gitlab.com/gitlab-com/runbooks/tree/master/logging/doc#pubsubbeat-vms).
+We use [Fluentd](https://gitlab.com/gitlab-com/runbooks/tree/master/logging/doc#fluentd)
+to parse our logs. Fluentd sends our logs to
+[Stackdriver Logging](https://gitlab.com/gitlab-com/runbooks/tree/master/logging/doc#stackdriver)
+and [Cloud Pub/Sub](https://gitlab.com/gitlab-com/runbooks/tree/master/logging/doc#cloud-pubsub).
+Stackdriver is used for storing logs long-term in Google Cold Storage (GCS).
+Cloud Pub/Sub is used to forward logs to an [Elastic cluster](https://gitlab.com/gitlab-com/runbooks/tree/master/logging/doc#elastic) using [`pubsubbeat`](https://gitlab.com/gitlab-com/runbooks/tree/master/logging/doc#pubsubbeat-vms).
You can view more information in our runbooks such as:
diff --git a/doc/user/group/epics/epic_boards.md b/doc/user/group/epics/epic_boards.md
index 2f9dc27d87f..c31b0c7f78a 100644
--- a/doc/user/group/epics/epic_boards.md
+++ b/doc/user/group/epics/epic_boards.md
@@ -25,7 +25,7 @@ To view an epic board, in a group, select **Epics > Boards**.
Prerequisites:
-- A minimum of [Reporter](../../permissions.md#group-members-permissions) access to a group in GitLab.
+- You must have at least the [Reporter role](../../permissions.md#group-members-permissions) for a group.
To create a new epic board:
@@ -49,7 +49,7 @@ To change these options later, [edit the board](#edit-the-scope-of-an-epic-board
Prerequisites:
-- A minimum of [Reporter](../../permissions.md#group-members-permissions) access to a group in GitLab.
+- You must have at least the [Reporter role](../../permissions.md#group-members-permissions) for a group.
- A minimum of two boards present in a group.
To delete the active epic board:
@@ -73,7 +73,7 @@ To delete the active epic board:
Prerequisites:
-- A minimum of [Reporter](../../permissions.md#group-members-permissions) access to a group in GitLab.
+- You must have at least the [Reporter role](../../permissions.md#group-members-permissions) for a group.
To create a new list:
@@ -90,7 +90,7 @@ list view that's removed. You can always create it again later if you need.
Prerequisites:
-- A minimum of [Reporter](../../permissions.md#group-members-permissions) access to a group in GitLab.
+- You must have at least the [Reporter role](../../permissions.md#group-members-permissions) for a group.
To remove a list from an epic board:
@@ -120,7 +120,7 @@ You can move epics and lists by dragging them.
Prerequisites:
-- A minimum of [Reporter](../../permissions.md#group-members-permissions) access to a group in GitLab.
+- You must have at least the [Reporter role](../../permissions.md#group-members-permissions) for a group.
To move an epic, select the epic card and drag it to another position in its current list or
into another list. Learn about possible effects in [Dragging epics between lists](#dragging-epics-between-lists).
@@ -143,7 +143,7 @@ and the target list.
Prerequisites:
-- A minimum of [Reporter](../../permissions.md#group-members-permissions) access to a group in GitLab.
+- You must have at least the [Reporter role](../../permissions.md#group-members-permissions) for a group.
To edit the scope of an epic board:
diff --git a/doc/user/packages/container_registry/index.md b/doc/user/packages/container_registry/index.md
index 9d65c5d37ad..d6e86e64e78 100644
--- a/doc/user/packages/container_registry/index.md
+++ b/doc/user/packages/container_registry/index.md
@@ -488,6 +488,10 @@ Cleanup policies can be run on all projects, with these exceptions:
Feature.disable(:container_expiration_policies_historic_entry, Project.find(<project id>))
```
+WARNING:
+For performance reasons, enabled cleanup policies are automatically disabled for projects on
+GitLab.com that don't have a container image.
+
### How the cleanup policy works
The cleanup policy collects all tags in the Container Registry and excludes tags
diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md
index 97296d22dd9..8dd8ed52dd7 100644
--- a/doc/user/project/clusters/index.md
+++ b/doc/user/project/clusters/index.md
@@ -31,7 +31,7 @@ Besides integration at the project level, Kubernetes clusters can also be
integrated at the [group level](../../group/clusters/index.md) or
[GitLab instance level](../../instance/clusters/index.md).
-To view your project level Kubernetes clusters, navigate to **Infrastructure > Kubernetes**
+To view your project level Kubernetes clusters, navigate to **Infrastructure > Kubernetes clusters**
from your project. On this page, you can [add a new cluster](#adding-and-removing-clusters)
and view information about your existing clusters, such as:
@@ -187,7 +187,7 @@ your cluster. This can cause deployment jobs to fail.
To clear the cache:
-1. Navigate to your project's **Infrastructure > Kubernetes** page, and select your cluster.
+1. Navigate to your project's **Infrastructure > Kubernetes clusters** page, and select your cluster.
1. Expand the **Advanced settings** section.
1. Click **Clear cluster cache**.
diff --git a/doc/user/project/description_templates.md b/doc/user/project/description_templates.md
index d2897c7310e..711d7f561e4 100644
--- a/doc/user/project/description_templates.md
+++ b/doc/user/project/description_templates.md
@@ -116,12 +116,13 @@ Only instance administrators can set instance-level templates.
To set the instance-level description template repository:
-1. Select the **Admin Area** icon (**{admin}**).
-1. Go to **Settings > Templates**.
+1. On the top bar, select **Menu >** **{admin}** **Admin**.
+1. On the left sidebar, select **Settings > Templates**.
+1. Expand **Templates**
1. From the dropdown, select your template project as the template repository at instance level.
1. Select **Save changes**.
-![Setting templates in the Admin Area](../admin_area/settings/img/file_template_admin_area.png)
+![Setting templates in the Admin Area](../admin_area/settings/img/file_template_admin_area_v14_0.png)
Learn more about [instance template repository](../admin_area/settings/instance_template_repository.md).
diff --git a/doc/user/project/merge_requests/approvals/settings.md b/doc/user/project/merge_requests/approvals/settings.md
index 97e4b7da396..b72a4125d0e 100644
--- a/doc/user/project/merge_requests/approvals/settings.md
+++ b/doc/user/project/merge_requests/approvals/settings.md
@@ -34,7 +34,7 @@ on merge requests, you can disable this setting:
1. Select the **Prevent users from modifying MR approval rules in merge requests** checkbox.
1. Select **Save changes**.
-TODO This change affects all open merge requests.
+This change affects all open merge requests.
## Reset approvals on push
diff --git a/doc/user/project/time_tracking.md b/doc/user/project/time_tracking.md
index 3c9b0341661..b7fd14ae74b 100644
--- a/doc/user/project/time_tracking.md
+++ b/doc/user/project/time_tracking.md
@@ -109,8 +109,15 @@ Default conversion rates are 1mo = 4w, 1w = 5d and 1d = 8h.
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/29469/) in GitLab 12.1.
-In GitLab self-managed instances, the display of time units can be limited to
-hours through the option in **Admin Area > Settings > Preferences** under **Localization**.
+In GitLab self-managed instances, you can limit the display of time units to
+hours.
+To do so:
+
+1. On the top bar, select **Menu >** **{admin}** **Admin**.
+1. On the left sidebar, select **Settings > Preferences**.
+1. Expand **Localization**.
+1. Under **Time tracking**, select the **Limit display of time tracking units to hours** checkbox.
+1. Select **Save changes**.
With this option enabled, `75h` is displayed instead of `1w 4d 3h`.
diff --git a/doc/user/shortcuts.md b/doc/user/shortcuts.md
index 6abbb128f49..37e89dd54db 100644
--- a/doc/user/shortcuts.md
+++ b/doc/user/shortcuts.md
@@ -81,7 +81,7 @@ relatively quickly to work, and they take you to another page in the project.
| <kbd>g</kbd> + <kbd>j</kbd> | Go to the CI/CD jobs list (**CI/CD > Jobs**). |
| <kbd>g</kbd> + <kbd>l</kbd> | Go to the project metrics (**Monitor > Metrics**). |
| <kbd>g</kbd> + <kbd>e</kbd> | Go to the project environments (**Deployments > Environments**). |
-| <kbd>g</kbd> + <kbd>k</kbd> | Go to the project Kubernetes cluster integration page (**Infrastructure > Kubernetes**). Note that you must have at least [`maintainer` permissions](permissions.md) to access this page. |
+| <kbd>g</kbd> + <kbd>k</kbd> | Go to the project Kubernetes cluster integration page (**Infrastructure > Kubernetes clusters**). Note that you must have at least [`maintainer` permissions](permissions.md) to access this page. |
| <kbd>g</kbd> + <kbd>s</kbd> | Go to the project snippets list (**Snippets**). |
| <kbd>g</kbd> + <kbd>w</kbd> | Go to the project wiki (**Wiki**), if enabled. |
diff --git a/lib/api/members.rb b/lib/api/members.rb
index 0956806da5b..70e13e8d4ae 100644
--- a/lib/api/members.rb
+++ b/lib/api/members.rb
@@ -97,6 +97,8 @@ module API
end
# rubocop: disable CodeReuse/ActiveRecord
post ":id/members" do
+ ::Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/333434')
+
source = find_source(source_type, params[:id])
authorize_admin_source!(source_type, source)
diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb
index 36f58d43a77..580c7042f1e 100644
--- a/lib/gitlab/auth.rb
+++ b/lib/gitlab/auth.rb
@@ -385,7 +385,7 @@ module Gitlab
end
def can_user_login_with_non_expired_password?(user)
- user.can?(:log_in) && !user.password_expired?
+ user.can?(:log_in) && !user.password_expired_if_applicable?
end
end
end
diff --git a/lib/gitlab/auth/current_user_mode.rb b/lib/gitlab/auth/current_user_mode.rb
index a6d706c2a49..fc391543f4d 100644
--- a/lib/gitlab/auth/current_user_mode.rb
+++ b/lib/gitlab/auth/current_user_mode.rb
@@ -27,22 +27,27 @@ module Gitlab
# will bypass the session check for a user that was already in admin mode
#
# If passed a block, it will surround the block execution and reset the session
- # bypass at the end; otherwise use manually '.reset_bypass_session!'
+ # bypass at the end; otherwise you must remember to call '.reset_bypass_session!'
def bypass_session!(admin_id)
Gitlab::SafeRequestStore[CURRENT_REQUEST_BYPASS_SESSION_ADMIN_ID_RS_KEY] = admin_id
+ # Bypassing the session invalidates the cached value of admin_mode?
+ # Any new calls need to be re-computed.
+ uncache_admin_mode_state(admin_id)
Gitlab::AppLogger.debug("Bypassing session in admin mode for: #{admin_id}")
- if block_given?
- begin
- yield
- ensure
- reset_bypass_session!
- end
+ return unless block_given?
+
+ begin
+ yield
+ ensure
+ reset_bypass_session!(admin_id)
end
end
- def reset_bypass_session!
+ def reset_bypass_session!(admin_id = nil)
+ # Restoring the session bypass invalidates the cached value of admin_mode?
+ uncache_admin_mode_state(admin_id)
Gitlab::SafeRequestStore.delete(CURRENT_REQUEST_BYPASS_SESSION_ADMIN_ID_RS_KEY)
end
@@ -50,10 +55,21 @@ module Gitlab
Gitlab::SafeRequestStore[CURRENT_REQUEST_BYPASS_SESSION_ADMIN_ID_RS_KEY]
end
+ def uncache_admin_mode_state(admin_id = nil)
+ if admin_id
+ key = { res: :current_user_mode, user: admin_id, method: :admin_mode? }
+ Gitlab::SafeRequestStore.delete(key)
+ else
+ Gitlab::SafeRequestStore.delete_if do |key|
+ key.is_a?(Hash) && key[:res] == :current_user_mode && key[:method] == :admin_mode?
+ end
+ end
+ end
+
# Store in the current request the provided user model (only if in admin mode)
# and yield
def with_current_admin(admin)
- return yield unless self.new(admin).admin_mode?
+ return yield unless new(admin).admin_mode?
Gitlab::SafeRequestStore[CURRENT_REQUEST_ADMIN_MODE_USER_RS_KEY] = admin
diff --git a/lib/gitlab/auth/user_access_denied_reason.rb b/lib/gitlab/auth/user_access_denied_reason.rb
index 6639000dba8..904759919ae 100644
--- a/lib/gitlab/auth/user_access_denied_reason.rb
+++ b/lib/gitlab/auth/user_access_denied_reason.rb
@@ -23,6 +23,8 @@ module Gitlab
"Your primary email address is not confirmed. "\
"Please check your inbox for the confirmation instructions. "\
"In case the link is expired, you can request a new confirmation email at #{Rails.application.routes.url_helpers.new_user_confirmation_url}"
+ when :blocked
+ "Your account has been blocked."
when :password_expired
"Your password expired. "\
"Please access GitLab from a web browser to update your password."
@@ -44,6 +46,8 @@ module Gitlab
:deactivated
elsif !@user.confirmed?
:unconfirmed
+ elsif @user.blocked?
+ :blocked
elsif @user.password_expired?
:password_expired
else
diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb
index c8e8f0bc1fc..e6d63969161 100644
--- a/lib/gitlab/ci/config/entry/job.rb
+++ b/lib/gitlab/ci/config/entry/job.rb
@@ -14,7 +14,7 @@ module Gitlab
ALLOWED_KEYS = %i[tags script type image services start_in artifacts
cache dependencies before_script after_script
environment coverage retry parallel interruptible timeout
- release secrets].freeze
+ release dast_configuration secrets].freeze
REQUIRED_BY_NEEDS = %i[stage].freeze
diff --git a/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml b/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml
index a2b112b8e9f..5521a4a781b 100644
--- a/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml
+++ b/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml
@@ -1,14 +1,21 @@
+# To use this template, add the following to your .gitlab-ci.yml file:
+#
+# include:
+# template: DAST.gitlab-ci.yml
+#
+# You also need to add a `dast` stage to your `stages:` configuration. A sample configuration for DAST:
+#
+# stages:
+# - build
+# - test
+# - deploy
+# - dast
+
# Read more about this feature here: https://docs.gitlab.com/ee/user/application_security/dast/
# Configure DAST with CI/CD variables (https://docs.gitlab.com/ee/ci/variables/README.html).
# List of available variables: https://docs.gitlab.com/ee/user/application_security/dast/#available-variables
-stages:
- - build
- - test
- - deploy
- - dast
-
variables:
DAST_VERSION: 2
# Setting this variable will affect all Security templates
diff --git a/lib/gitlab/ci/templates/Security/DAST.latest.gitlab-ci.yml b/lib/gitlab/ci/templates/Security/DAST.latest.gitlab-ci.yml
index 6834766da3d..e936364c86c 100644
--- a/lib/gitlab/ci/templates/Security/DAST.latest.gitlab-ci.yml
+++ b/lib/gitlab/ci/templates/Security/DAST.latest.gitlab-ci.yml
@@ -46,13 +46,10 @@ dast:
$REVIEW_DISABLED && $DAST_WEBSITE == null &&
$DAST_API_SPECIFICATION == null
when: never
- - if: $CI_MERGE_REQUEST_IID &&
- $CI_KUBERNETES_ACTIVE &&
- $GITLAB_FEATURES =~ /\bdast\b/
- - if: $CI_MERGE_REQUEST_IID && ($DAST_WEBSITE || $DAST_API_SPECIFICATION)
- - if: $CI_OPEN_MERGE_REQUESTS
- when: never
- if: $CI_COMMIT_BRANCH &&
$CI_KUBERNETES_ACTIVE &&
$GITLAB_FEATURES =~ /\bdast\b/
- - if: $CI_COMMIT_BRANCH && ($DAST_WEBSITE || $DAST_API_SPECIFICATION)
+ - if: $CI_COMMIT_BRANCH &&
+ $DAST_WEBSITE
+ - if: $CI_COMMIT_BRANCH &&
+ $DAST_API_SPECIFICATION
diff --git a/lib/gitlab/ci/yaml_processor/result.rb b/lib/gitlab/ci/yaml_processor/result.rb
index 15cc0c28296..dd5107bad9a 100644
--- a/lib/gitlab/ci/yaml_processor/result.rb
+++ b/lib/gitlab/ci/yaml_processor/result.rb
@@ -148,3 +148,5 @@ module Gitlab
end
end
end
+
+Gitlab::Ci::YamlProcessor::Result.prepend_mod_with('Gitlab::Ci::YamlProcessor::Result')
diff --git a/lib/gitlab/database/migrations/observers.rb b/lib/gitlab/database/migrations/observers.rb
index b65a303ef30..979a098d699 100644
--- a/lib/gitlab/database/migrations/observers.rb
+++ b/lib/gitlab/database/migrations/observers.rb
@@ -8,7 +8,8 @@ module Gitlab
[
TotalDatabaseSizeChange.new,
QueryStatistics.new,
- QueryLog.new
+ QueryLog.new,
+ QueryDetails.new
]
end
end
diff --git a/lib/gitlab/database/migrations/observers/query_details.rb b/lib/gitlab/database/migrations/observers/query_details.rb
new file mode 100644
index 00000000000..52b6464d449
--- /dev/null
+++ b/lib/gitlab/database/migrations/observers/query_details.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Database
+ module Migrations
+ module Observers
+ class QueryDetails < MigrationObserver
+ def before
+ @file_path = File.join(Instrumentation::RESULT_DIR, 'current-details.json')
+ @file = File.open(@file_path, 'wb')
+ @writer = Oj::StreamWriter.new(@file, {})
+ @writer.push_array
+ @subscriber = ActiveSupport::Notifications.subscribe('sql.active_record') do |*args|
+ record_sql_event(*args)
+ end
+ end
+
+ def after
+ ActiveSupport::Notifications.unsubscribe(@subscriber)
+ @writer.pop_all
+ @writer.flush
+ @file.close
+ end
+
+ def record(observation)
+ File.rename(@file_path, File.join(Instrumentation::RESULT_DIR, "#{observation.migration}-query-details.json"))
+ end
+
+ def record_sql_event(_name, started, finished, _unique_id, payload)
+ @writer.push_value({
+ start_time: started.iso8601(6),
+ end_time: finished.iso8601(6),
+ sql: payload[:sql],
+ binds: payload[:type_casted_binds]
+ })
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/exclusive_lease.rb b/lib/gitlab/exclusive_lease.rb
index 6749bd6ca60..75d07a36dcd 100644
--- a/lib/gitlab/exclusive_lease.rb
+++ b/lib/gitlab/exclusive_lease.rb
@@ -36,6 +36,28 @@ module Gitlab
end
end
+ # yield to the {block} at most {count} times per {period}
+ #
+ # Defaults to once per hour.
+ #
+ # For example:
+ #
+ # # toot the train horn at most every 20min:
+ # throttle(locomotive.id, count: 3, period: 1.hour) { toot_train_horn }
+ # # Brake suddenly at most once every minute:
+ # throttle(locomotive.id, period: 1.minute) { brake_suddenly }
+ # # Specify a uniqueness group:
+ # throttle(locomotive.id, group: :locomotive_brake) { brake_suddenly }
+ #
+ # If a group is not specified, each block will get a separate group to itself.
+ def self.throttle(key, group: nil, period: 1.hour, count: 1, &block)
+ group ||= block.source_location.join(':')
+
+ return if new("el:throttle:#{group}:#{key}", timeout: period.to_i / count).waiting?
+
+ yield
+ end
+
def self.cancel(key, uuid)
return unless key.present?
@@ -79,6 +101,11 @@ module Gitlab
end
end
+ # This lease is waiting to obtain
+ def waiting?
+ !try_obtain
+ end
+
# Try to renew an existing lease. Return lease UUID on success,
# false if the lease is taken by a different UUID or inexistent.
def renew
diff --git a/lib/gitlab/git/remote_mirror.rb b/lib/gitlab/git/remote_mirror.rb
index d9f51a7e844..eb368af199d 100644
--- a/lib/gitlab/git/remote_mirror.rb
+++ b/lib/gitlab/git/remote_mirror.rb
@@ -5,11 +5,12 @@ module Gitlab
class RemoteMirror
include Gitlab::Git::WrapsGitalyErrors
- attr_reader :repository, :ref_name, :only_branches_matching, :ssh_key, :known_hosts, :keep_divergent_refs
+ attr_reader :repository, :ref_name, :remote_url, :only_branches_matching, :ssh_key, :known_hosts, :keep_divergent_refs
- def initialize(repository, ref_name, only_branches_matching: [], ssh_key: nil, known_hosts: nil, keep_divergent_refs: false)
+ def initialize(repository, ref_name, remote_url, only_branches_matching: [], ssh_key: nil, known_hosts: nil, keep_divergent_refs: false)
@repository = repository
@ref_name = ref_name
+ @remote_url = remote_url
@only_branches_matching = only_branches_matching
@ssh_key = ssh_key
@known_hosts = known_hosts
@@ -20,6 +21,7 @@ module Gitlab
wrapped_gitaly_errors do
repository.gitaly_remote_client.update_remote_mirror(
ref_name,
+ remote_url,
only_branches_matching,
ssh_key: ssh_key,
known_hosts: known_hosts,
diff --git a/lib/gitlab/gitaly_client/remote_service.rb b/lib/gitlab/gitaly_client/remote_service.rb
index 1f360385111..487127b7b74 100644
--- a/lib/gitlab/gitaly_client/remote_service.rb
+++ b/lib/gitlab/gitaly_client/remote_service.rb
@@ -55,13 +55,18 @@ module Gitlab
encode_utf8(response.ref)
end
- def update_remote_mirror(ref_name, only_branches_matching, ssh_key: nil, known_hosts: nil, keep_divergent_refs: false)
+ def update_remote_mirror(ref_name, remote_url, only_branches_matching, ssh_key: nil, known_hosts: nil, keep_divergent_refs: false)
req_enum = Enumerator.new do |y|
first_request = Gitaly::UpdateRemoteMirrorRequest.new(
- repository: @gitaly_repo,
- ref_name: ref_name
+ repository: @gitaly_repo
)
+ if remote_url
+ first_request.remote = Gitaly::UpdateRemoteMirrorRequest::Remote.new(url: remote_url)
+ else
+ first_request.ref_name = ref_name
+ end
+
first_request.ssh_key = ssh_key if ssh_key.present?
first_request.known_hosts = known_hosts if known_hosts.present?
first_request.keep_divergent_refs = keep_divergent_refs
diff --git a/lib/gitlab/lfs_token.rb b/lib/gitlab/lfs_token.rb
index c7f2adb27d1..2e8564b6e00 100644
--- a/lib/gitlab/lfs_token.rb
+++ b/lib/gitlab/lfs_token.rb
@@ -52,7 +52,7 @@ module Gitlab
def valid_user?
return true unless user?
- !actor.blocked? && (!actor.allow_password_authentication? || !actor.password_expired?)
+ !actor.blocked? && !actor.password_expired_if_applicable?
end
def authentication_payload(repository_http_path)
diff --git a/lib/gitlab/pagination/keyset/paginator.rb b/lib/gitlab/pagination/keyset/paginator.rb
index 2ec4472fcd6..1c71549d86a 100644
--- a/lib/gitlab/pagination/keyset/paginator.rb
+++ b/lib/gitlab/pagination/keyset/paginator.rb
@@ -26,7 +26,7 @@ module Gitlab
# per_page - Number of items per page.
# cursor_converter - Object that serializes and de-serializes the cursor attributes. Implements dump and parse methods.
# direction_key - Symbol that will be the hash key of the direction within the cursor. (default: _kd => keyset direction)
- def initialize(scope:, cursor: nil, per_page: 20, cursor_converter: Base64CursorConverter, direction_key: :_kd)
+ def initialize(scope:, cursor: nil, per_page: 20, cursor_converter: Base64CursorConverter, direction_key: :_kd, keyset_order_options: {})
@keyset_scope = build_scope(scope)
@order = Gitlab::Pagination::Keyset::Order.extract_keyset_order_object(@keyset_scope)
@per_page = per_page
@@ -36,6 +36,7 @@ module Gitlab
@at_last_page = false
@at_first_page = false
@cursor_attributes = decode_cursor_attributes(cursor)
+ @keyset_order_options = keyset_order_options
set_pagination_helper_flags!
end
@@ -45,13 +46,13 @@ module Gitlab
@records ||= begin
items = if paginate_backward?
reversed_order
- .apply_cursor_conditions(keyset_scope, cursor_attributes)
+ .apply_cursor_conditions(keyset_scope, cursor_attributes, keyset_order_options)
.reorder(reversed_order)
.limit(per_page_plus_one)
.to_a
else
order
- .apply_cursor_conditions(keyset_scope, cursor_attributes)
+ .apply_cursor_conditions(keyset_scope, cursor_attributes, keyset_order_options)
.limit(per_page_plus_one)
.to_a
end
@@ -120,7 +121,7 @@ module Gitlab
private
- attr_reader :keyset_scope, :order, :per_page, :cursor_converter, :direction_key, :cursor_attributes
+ attr_reader :keyset_scope, :order, :per_page, :cursor_converter, :direction_key, :cursor_attributes, :keyset_order_options
delegate :reversed_order, to: :order
diff --git a/lib/gitlab/safe_request_store.rb b/lib/gitlab/safe_request_store.rb
index d146913bdb3..664afd1cc21 100644
--- a/lib/gitlab/safe_request_store.rb
+++ b/lib/gitlab/safe_request_store.rb
@@ -20,6 +20,15 @@ module Gitlab
end
end
+ # Access to the backing storage of the request store. This returns an object
+ # with `[]` and `[]=` methods that does not discard values.
+ #
+ # This can be useful if storage is needed for a delimited purpose, and the
+ # forgetful nature of the null store is undesirable.
+ def self.storage
+ store.store
+ end
+
# This method accept an options hash to be compatible with
# ActiveSupport::Cache::Store#write method. The options are
# not passed to the underlying cache implementation because
@@ -27,5 +36,11 @@ module Gitlab
def self.write(key, value, options = nil)
store.write(key, value)
end
+
+ def self.delete_if(&block)
+ return unless RequestStore.active?
+
+ storage.delete_if { |k, v| block.call(k) }
+ end
end
end
diff --git a/lib/sidebars/projects/menus/ci_cd_menu.rb b/lib/sidebars/projects/menus/ci_cd_menu.rb
index 042ad17fdfc..f85a9faacd3 100644
--- a/lib/sidebars/projects/menus/ci_cd_menu.rb
+++ b/lib/sidebars/projects/menus/ci_cd_menu.rb
@@ -61,6 +61,10 @@ module Sidebars
pipelines#index
pipelines#show
pipelines#new
+ pipelines#dag
+ pipelines#failures
+ pipelines#builds
+ pipelines#test_report
]
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index d0d24dfeb2e..f34bea613e8 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -13731,9 +13731,6 @@ msgstr ""
msgid "Feature Flags"
msgstr ""
-msgid "Feature flag is not enabled on the environment's project."
-msgstr ""
-
msgid "Feature flag status"
msgstr ""
@@ -28273,6 +28270,9 @@ msgstr ""
msgid "Runners|Name"
msgstr ""
+msgid "Runners|New registration token generated!"
+msgstr ""
+
msgid "Runners|New runner, has not connected yet"
msgstr ""
@@ -31227,6 +31227,9 @@ msgstr ""
msgid "StorageSize|Unknown"
msgstr ""
+msgid "Strikethrough"
+msgstr ""
+
msgid "Subgroup information"
msgstr ""
@@ -39183,6 +39186,9 @@ msgstr ""
msgid "must be greater than start date"
msgstr ""
+msgid "must be inside the fork network"
+msgstr ""
+
msgid "must have a unique schedule, status, and elapsed time"
msgstr ""
diff --git a/qa/qa/specs/features/browser_ui/non_devops/performance_bar_spec.rb b/qa/qa/specs/features/browser_ui/non_devops/performance_bar_spec.rb
index de425dde6c0..a0f613cfda2 100644
--- a/qa/qa/specs/features/browser_ui/non_devops/performance_bar_spec.rb
+++ b/qa/qa/specs/features/browser_ui/non_devops/performance_bar_spec.rb
@@ -20,7 +20,7 @@ module QA
end
end
- it 'shows results for the original request and AJAX requests', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/478', quarantine: { only: { pipeline: :main }, issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/323051', type: :bug } do
+ it 'shows results for the original request and AJAX requests', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/478' do
# Issue pages always make AJAX requests
Resource::Issue.fabricate_via_browser_ui! do |issue|
issue.title = 'Performance bar test'
diff --git a/spec/controllers/admin/cohorts_controller_spec.rb b/spec/controllers/admin/cohorts_controller_spec.rb
index ba5406f25ab..d271276a3e4 100644
--- a/spec/controllers/admin/cohorts_controller_spec.rb
+++ b/spec/controllers/admin/cohorts_controller_spec.rb
@@ -9,9 +9,9 @@ RSpec.describe Admin::CohortsController do
sign_in(user)
end
- it 'redirects to Overview->Users' do
- get :index
-
- expect(response).to redirect_to(cohorts_admin_users_path)
+ describe 'GET #index' do
+ it_behaves_like 'tracking unique visits', :index do
+ let(:target_id) { 'i_analytics_cohorts' }
+ end
end
end
diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb
index da57e5f8a92..6dc5c38cb76 100644
--- a/spec/controllers/admin/users_controller_spec.rb
+++ b/spec/controllers/admin/users_controller_spec.rb
@@ -54,12 +54,6 @@ RSpec.describe Admin::UsersController do
end
end
- describe 'GET #cohorts' do
- it_behaves_like 'tracking unique visits', :cohorts do
- let(:target_id) { 'i_analytics_cohorts' }
- end
- end
-
describe 'GET :id' do
it 'finds a user case-insensitively' do
user = create(:user, username: 'CaseSensitive')
diff --git a/spec/controllers/projects/merge_requests/content_controller_spec.rb b/spec/controllers/projects/merge_requests/content_controller_spec.rb
index 0eaa528a330..0116071bddf 100644
--- a/spec/controllers/projects/merge_requests/content_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests/content_controller_spec.rb
@@ -57,17 +57,6 @@ RSpec.describe Projects::MergeRequests::ContentController do
expect(response.headers['Poll-Interval']).to eq('10000')
end
- context 'when async_mergeability_check param is passed' do
- it 'checks mergeability asynchronously' do
- expect_next_instance_of(MergeRequests::MergeabilityCheckService) do |service|
- expect(service).not_to receive(:execute)
- expect(service).to receive(:async_execute).and_call_original
- end
-
- do_request(:widget, { async_mergeability_check: true })
- end
- end
-
context 'merged merge request' do
let(:merge_request) do
create(:merged_merge_request, :with_test_reports, target_project: project, source_project: project)
diff --git a/spec/controllers/projects/protected_branches_controller_spec.rb b/spec/controllers/projects/protected_branches_controller_spec.rb
index a0cb5c1473a..dcfccc00347 100644
--- a/spec/controllers/projects/protected_branches_controller_spec.rb
+++ b/spec/controllers/projects/protected_branches_controller_spec.rb
@@ -42,7 +42,7 @@ RSpec.describe Projects::ProtectedBranchesController do
context 'when a policy restricts rule deletion' do
before do
- policy = instance_double(ProtectedBranchPolicy, can?: false)
+ policy = instance_double(ProtectedBranchPolicy, allowed?: false)
allow(ProtectedBranchPolicy).to receive(:new).and_return(policy)
end
@@ -70,7 +70,7 @@ RSpec.describe Projects::ProtectedBranchesController do
context 'when a policy restricts rule deletion' do
before do
- policy = instance_double(ProtectedBranchPolicy, can?: false)
+ policy = instance_double(ProtectedBranchPolicy, allowed?: false)
allow(ProtectedBranchPolicy).to receive(:new).and_return(policy)
end
@@ -97,7 +97,7 @@ RSpec.describe Projects::ProtectedBranchesController do
context 'when a policy restricts rule deletion' do
before do
- policy = instance_double(ProtectedBranchPolicy, can?: false)
+ policy = instance_double(ProtectedBranchPolicy, allowed?: false)
allow(ProtectedBranchPolicy).to receive(:new).and_return(policy)
end
diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb
index fd570ca9c50..1dd2839aa46 100644
--- a/spec/factories/integrations.rb
+++ b/spec/factories/integrations.rb
@@ -12,7 +12,7 @@ FactoryBot.define do
issue_tracker
end
- factory :emails_on_push_service, class: 'Integrations::EmailsOnPush' do
+ factory :emails_on_push_integration, class: 'Integrations::EmailsOnPush' do
project
type { 'EmailsOnPushService' }
active { true }
@@ -103,7 +103,7 @@ FactoryBot.define do
issue_tracker
end
- factory :ewm_service, class: 'Integrations::Ewm' do
+ factory :ewm_integration, class: 'Integrations::Ewm' do
project
active { true }
issue_tracker
@@ -127,7 +127,7 @@ FactoryBot.define do
end
end
- factory :external_wiki_service, class: 'Integrations::ExternalWiki' do
+ factory :external_wiki_integration, class: 'Integrations::ExternalWiki' do
project
type { 'ExternalWikiService' }
active { true }
diff --git a/spec/factories/packages/package_file.rb b/spec/factories/packages/package_file.rb
index b02af85dbeb..d82fbe02311 100644
--- a/spec/factories/packages/package_file.rb
+++ b/spec/factories/packages/package_file.rb
@@ -208,6 +208,8 @@ FactoryBot.define do
transient do
without_loaded_metadatum { false }
+ package_name { package&.name || 'foo' }
+ sequence(:package_version) { |n| package&.version || "v#{n}" }
channel { 'stable' }
end
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index cc561ef65a2..6641d8749f9 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -426,7 +426,7 @@ FactoryBot.define do
factory :ewm_project, parent: :project do
has_external_issue_tracker { true }
- ewm_service
+ ewm_integration
end
factory :project_with_design, parent: :project do
diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb
index 6d5944002a1..2b627707ff2 100644
--- a/spec/features/admin/admin_users_spec.rb
+++ b/spec/features/admin/admin_users_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe "Admin::Users" do
let(:active_tab_selector) { '.nav-link.active' }
it 'links to the Users tab' do
- visit cohorts_admin_users_path
+ visit admin_cohorts_path
within tabs_selector do
click_link 'Users'
@@ -35,14 +35,14 @@ RSpec.describe "Admin::Users" do
expect(page).to have_selector active_tab_selector, text: 'Cohorts'
end
- expect(page).to have_current_path(cohorts_admin_users_path)
+ expect(page).to have_current_path(admin_cohorts_path)
expect(page).to have_selector active_tab_selector, text: 'Cohorts'
end
it 'redirects legacy route' do
visit admin_users_path(tab: 'cohorts')
- expect(page).to have_current_path(cohorts_admin_users_path)
+ expect(page).to have_current_path(admin_cohorts_path)
end
end
diff --git a/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb b/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb
index d555519eb43..85eb956033b 100644
--- a/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb
+++ b/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb
@@ -25,8 +25,6 @@ RSpec.describe 'Merge request > User sees pipelines triggered by merge request',
}
end
- let_it_be(:runner) { create(:ci_runner, :online) }
-
before do
stub_application_setting(auto_devops_enabled: false)
stub_ci_pipeline_yaml_file(YAML.dump(config))
diff --git a/spec/features/projects/active_tabs_spec.rb b/spec/features/projects/active_tabs_spec.rb
index b333f64aa87..39950adc83f 100644
--- a/spec/features/projects/active_tabs_spec.rb
+++ b/spec/features/projects/active_tabs_spec.rb
@@ -182,4 +182,55 @@ RSpec.describe 'Project active tab' do
it_behaves_like 'page has active sub tab', _('CI/CD')
end
end
+
+ context 'on project CI/CD' do
+ context 'browsing Pipelines tabs' do
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
+
+ context 'Pipeline tab' do
+ before do
+ visit project_pipeline_path(project, pipeline)
+ end
+
+ it_behaves_like 'page has active tab', _('CI/CD')
+ it_behaves_like 'page has active sub tab', _('Pipelines')
+ end
+
+ context 'Needs tab' do
+ before do
+ visit dag_project_pipeline_path(project, pipeline)
+ end
+
+ it_behaves_like 'page has active tab', _('CI/CD')
+ it_behaves_like 'page has active sub tab', _('Pipelines')
+ end
+
+ context 'Builds tab' do
+ before do
+ visit builds_project_pipeline_path(project, pipeline)
+ end
+
+ it_behaves_like 'page has active tab', _('CI/CD')
+ it_behaves_like 'page has active sub tab', _('Pipelines')
+ end
+
+ context 'Failures tab' do
+ before do
+ visit failures_project_pipeline_path(project, pipeline)
+ end
+
+ it_behaves_like 'page has active tab', _('CI/CD')
+ it_behaves_like 'page has active sub tab', _('Pipelines')
+ end
+
+ context 'Test Report tab' do
+ before do
+ visit test_report_project_pipeline_path(project, pipeline)
+ end
+
+ it_behaves_like 'page has active tab', _('CI/CD')
+ it_behaves_like 'page has active sub tab', _('Pipelines')
+ end
+ end
+ end
end
diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb
index 1d7099ba443..7010059a7ff 100644
--- a/spec/features/users/login_spec.rb
+++ b/spec/features/users/login_spec.rb
@@ -128,6 +128,20 @@ RSpec.describe 'Login' do
end
end
end
+
+ context 'when resending the confirmation email' do
+ it 'redirects to the "almost there" page' do
+ stub_feature_flags(soft_email_confirmation: false)
+
+ user = create(:user)
+
+ visit new_user_confirmation_path
+ fill_in 'user_email', with: user.email
+ click_button 'Resend'
+
+ expect(current_path).to eq users_almost_there_path
+ end
+ end
end
describe 'with the ghost user' do
diff --git a/spec/frontend/content_editor/components/top_toolbar_spec.js b/spec/frontend/content_editor/components/top_toolbar_spec.js
index 0a1405a1774..0d55fa730ae 100644
--- a/spec/frontend/content_editor/components/top_toolbar_spec.js
+++ b/spec/frontend/content_editor/components/top_toolbar_spec.js
@@ -42,6 +42,7 @@ describe('content_editor/components/top_toolbar', () => {
testId | controlProps
${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }}
${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }}
+ ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike' }}
${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }}
${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }}
${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }}
diff --git a/spec/frontend/environments/deploy_board_component_spec.js b/spec/frontend/environments/deploy_board_component_spec.js
index 53220341a62..24e94867afd 100644
--- a/spec/frontend/environments/deploy_board_component_spec.js
+++ b/spec/frontend/environments/deploy_board_component_spec.js
@@ -12,7 +12,6 @@ describe('Deploy Board', () => {
const createComponent = (props = {}) =>
mount(Vue.extend(DeployBoard), {
- provide: { glFeatures: { canaryIngressWeightControl: true } },
propsData: {
deployBoardData: deployBoardMockData,
isLoading: false,
diff --git a/spec/frontend/fixtures/api_markdown.yml b/spec/frontend/fixtures/api_markdown.yml
index a1ea2806879..3274e914f03 100644
--- a/spec/frontend/fixtures/api_markdown.yml
+++ b/spec/frontend/fixtures/api_markdown.yml
@@ -1,6 +1,6 @@
# This data file drives the specs in
# spec/frontend/fixtures/api_markdown.rb and
-# spec/frontend/rich_text_editor/extensions/markdown_processing_spec.js
+# spec/frontend/content_editor/extensions/markdown_processing_spec.js
---
- name: bold
markdown: '**bold**'
@@ -8,6 +8,8 @@
markdown: '_emphasized text_'
- name: inline_code
markdown: '`code`'
+- name: strike
+ markdown: '~~del~~'
- name: link
markdown: '[GitLab](https://gitlab.com)'
- name: code_block
diff --git a/spec/frontend/fixtures/releases.rb b/spec/frontend/fixtures/releases.rb
index 1882ac49fd6..ac34400bc01 100644
--- a/spec/frontend/fixtures/releases.rb
+++ b/spec/frontend/fixtures/releases.rb
@@ -146,6 +146,7 @@ RSpec.describe 'Releases (JavaScript fixtures)' do
post_graphql(query, current_user: admin, variables: { fullPath: project.full_path })
expect_graphql_errors_to_be_empty
+ expect(graphql_data_at(:project, :releases)).to be_present
end
it "graphql/#{one_release_query_path}.json" do
@@ -154,6 +155,7 @@ RSpec.describe 'Releases (JavaScript fixtures)' do
post_graphql(query, current_user: admin, variables: { fullPath: project.full_path, tagName: release.tag })
expect_graphql_errors_to_be_empty
+ expect(graphql_data_at(:project, :release)).to be_present
end
it "graphql/#{one_release_for_editing_query_path}.json" do
@@ -162,6 +164,7 @@ RSpec.describe 'Releases (JavaScript fixtures)' do
post_graphql(query, current_user: admin, variables: { fullPath: project.full_path, tagName: release.tag })
expect_graphql_errors_to_be_empty
+ expect(graphql_data_at(:project, :release)).to be_present
end
end
end
diff --git a/spec/frontend/issuable_list/components/issuable_list_root_spec.js b/spec/frontend/issuable_list/components/issuable_list_root_spec.js
index 38d6d6d86bc..7dddd2c3405 100644
--- a/spec/frontend/issuable_list/components/issuable_list_root_spec.js
+++ b/spec/frontend/issuable_list/components/issuable_list_root_spec.js
@@ -1,4 +1,4 @@
-import { GlSkeletonLoading, GlPagination } from '@gitlab/ui';
+import { GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import VueDraggable from 'vuedraggable';
@@ -11,9 +11,12 @@ import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filte
import { mockIssuableListProps, mockIssuables } from '../mock_data';
-const createComponent = ({ props = mockIssuableListProps, data = {} } = {}) =>
+const createComponent = ({ props = {}, data = {} } = {}) =>
shallowMount(IssuableListRoot, {
- propsData: props,
+ propsData: {
+ ...mockIssuableListProps,
+ ...props,
+ },
data() {
return data;
},
@@ -34,6 +37,7 @@ describe('IssuableListRoot', () => {
let wrapper;
const findFilteredSearchBar = () => wrapper.findComponent(FilteredSearchBar);
+ const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination);
const findGlPagination = () => wrapper.findComponent(GlPagination);
const findIssuableItem = () => wrapper.findComponent(IssuableItem);
const findIssuableTabs = () => wrapper.findComponent(IssuableTabs);
@@ -189,15 +193,15 @@ describe('IssuableListRoot', () => {
});
describe('template', () => {
- beforeEach(() => {
+ it('renders component container element with class "issuable-list-container"', () => {
wrapper = createComponent();
- });
- it('renders component container element with class "issuable-list-container"', () => {
expect(wrapper.classes()).toContain('issuable-list-container');
});
it('renders issuable-tabs component', () => {
+ wrapper = createComponent();
+
const tabsEl = findIssuableTabs();
expect(tabsEl.exists()).toBe(true);
@@ -209,6 +213,8 @@ describe('IssuableListRoot', () => {
});
it('renders contents for slot "nav-actions" within issuable-tab component', () => {
+ wrapper = createComponent();
+
const buttonEl = findIssuableTabs().find('button.js-new-issuable');
expect(buttonEl.exists()).toBe(true);
@@ -216,6 +222,8 @@ describe('IssuableListRoot', () => {
});
it('renders filtered-search-bar component', () => {
+ wrapper = createComponent();
+
const searchEl = findFilteredSearchBar();
const {
namespace,
@@ -239,12 +247,8 @@ describe('IssuableListRoot', () => {
});
});
- it('renders gl-loading-icon when `issuablesLoading` prop is true', async () => {
- wrapper.setProps({
- issuablesLoading: true,
- });
-
- await wrapper.vm.$nextTick();
+ it('renders gl-loading-icon when `issuablesLoading` prop is true', () => {
+ wrapper = createComponent({ props: { issuablesLoading: true } });
expect(wrapper.findAllComponents(GlSkeletonLoading)).toHaveLength(
wrapper.vm.skeletonItemCount,
@@ -252,6 +256,8 @@ describe('IssuableListRoot', () => {
});
it('renders issuable-item component for each item within `issuables` array', () => {
+ wrapper = createComponent();
+
const itemsEl = wrapper.findAllComponents(IssuableItem);
const mockIssuable = mockIssuableListProps.issuables[0];
@@ -262,28 +268,23 @@ describe('IssuableListRoot', () => {
});
});
- it('renders contents for slot "empty-state" when `issuablesLoading` is false and `issuables` is empty', async () => {
- wrapper.setProps({
- issuables: [],
- });
-
- await wrapper.vm.$nextTick();
+ it('renders contents for slot "empty-state" when `issuablesLoading` is false and `issuables` is empty', () => {
+ wrapper = createComponent({ props: { issuables: [] } });
expect(wrapper.find('p.js-issuable-empty-state').exists()).toBe(true);
expect(wrapper.find('p.js-issuable-empty-state').text()).toBe('Issuable empty state');
});
- it('renders gl-pagination when `showPaginationControls` prop is true', async () => {
- wrapper.setProps({
- showPaginationControls: true,
- totalItems: 10,
+ it('renders only gl-pagination when `showPaginationControls` prop is true', () => {
+ wrapper = createComponent({
+ props: {
+ showPaginationControls: true,
+ totalItems: 10,
+ },
});
- await wrapper.vm.$nextTick();
-
- const paginationEl = findGlPagination();
- expect(paginationEl.exists()).toBe(true);
- expect(paginationEl.props()).toMatchObject({
+ expect(findGlKeysetPagination().exists()).toBe(false);
+ expect(findGlPagination().props()).toMatchObject({
perPage: 20,
value: 1,
prevPage: 0,
@@ -292,32 +293,47 @@ describe('IssuableListRoot', () => {
align: 'center',
});
});
- });
- describe('events', () => {
- beforeEach(() => {
+ it('renders only gl-keyset-pagination when `showPaginationControls` and `useKeysetPagination` props are true', () => {
wrapper = createComponent({
- data: {
- checkedIssuables: {
- [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] },
- },
+ props: {
+ hasNextPage: true,
+ hasPreviousPage: true,
+ showPaginationControls: true,
+ useKeysetPagination: true,
},
});
+
+ expect(findGlPagination().exists()).toBe(false);
+ expect(findGlKeysetPagination().props()).toMatchObject({
+ hasNextPage: true,
+ hasPreviousPage: true,
+ });
});
+ });
+
+ describe('events', () => {
+ const data = {
+ checkedIssuables: {
+ [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] },
+ },
+ };
it('issuable-tabs component emits `click-tab` event on `click-tab` event', () => {
+ wrapper = createComponent({ data });
+
findIssuableTabs().vm.$emit('click');
expect(wrapper.emitted('click-tab')).toBeTruthy();
});
- it('sets all issuables as checked when filtered-search-bar component emits `checked-input` event', async () => {
+ it('sets all issuables as checked when filtered-search-bar component emits `checked-input` event', () => {
+ wrapper = createComponent({ data });
+
const searchEl = findFilteredSearchBar();
searchEl.vm.$emit('checked-input', true);
- await wrapper.vm.$nextTick();
-
expect(searchEl.emitted('checked-input')).toBeTruthy();
expect(searchEl.emitted('checked-input').length).toBe(1);
@@ -328,6 +344,8 @@ describe('IssuableListRoot', () => {
});
it('filtered-search-bar component emits `filter` event on `onFilter` & `sort` event on `onSort` events', () => {
+ wrapper = createComponent({ data });
+
const searchEl = findFilteredSearchBar();
searchEl.vm.$emit('onFilter');
@@ -336,13 +354,13 @@ describe('IssuableListRoot', () => {
expect(wrapper.emitted('sort')).toBeTruthy();
});
- it('sets an issuable as checked when issuable-item component emits `checked-input` event', async () => {
+ it('sets an issuable as checked when issuable-item component emits `checked-input` event', () => {
+ wrapper = createComponent({ data });
+
const issuableItem = wrapper.findAllComponents(IssuableItem).at(0);
issuableItem.vm.$emit('checked-input', true);
- await wrapper.vm.$nextTick();
-
expect(issuableItem.emitted('checked-input')).toBeTruthy();
expect(issuableItem.emitted('checked-input').length).toBe(1);
@@ -353,27 +371,45 @@ describe('IssuableListRoot', () => {
});
it('emits `update-legacy-bulk-edit` when filtered-search-bar checkbox is checked', () => {
+ wrapper = createComponent({ data });
+
findFilteredSearchBar().vm.$emit('checked-input');
expect(wrapper.emitted('update-legacy-bulk-edit')).toEqual([[]]);
});
it('emits `update-legacy-bulk-edit` when issuable-item checkbox is checked', () => {
+ wrapper = createComponent({ data });
+
findIssuableItem().vm.$emit('checked-input');
expect(wrapper.emitted('update-legacy-bulk-edit')).toEqual([[]]);
});
- it('gl-pagination component emits `page-change` event on `input` event', async () => {
- wrapper.setProps({
- showPaginationControls: true,
- });
-
- await wrapper.vm.$nextTick();
+ it('gl-pagination component emits `page-change` event on `input` event', () => {
+ wrapper = createComponent({ data, props: { showPaginationControls: true } });
findGlPagination().vm.$emit('input');
expect(wrapper.emitted('page-change')).toBeTruthy();
});
+
+ it.each`
+ event | glKeysetPaginationEvent
+ ${'next-page'} | ${'next'}
+ ${'previous-page'} | ${'prev'}
+ `(
+ 'emits `$event` event when gl-keyset-pagination emits `$glKeysetPaginationEvent` event',
+ ({ event, glKeysetPaginationEvent }) => {
+ wrapper = createComponent({
+ data,
+ props: { showPaginationControls: true, useKeysetPagination: true },
+ });
+
+ findGlKeysetPagination().vm.$emit(glKeysetPaginationEvent);
+
+ expect(wrapper.emitted(event)).toEqual([[]]);
+ },
+ );
});
describe('manual sorting', () => {
diff --git a/spec/frontend/issues_list/components/issue_card_time_info_spec.js b/spec/frontend/issues_list/components/issue_card_time_info_spec.js
index 614ad586ec9..634687e77ab 100644
--- a/spec/frontend/issues_list/components/issue_card_time_info_spec.js
+++ b/spec/frontend/issues_list/components/issue_card_time_info_spec.js
@@ -13,12 +13,10 @@ describe('IssuesListApp component', () => {
dueDate: '2020-12-17',
startDate: '2020-12-10',
title: 'My milestone',
- webUrl: '/milestone/webUrl',
+ webPath: '/milestone/webPath',
},
dueDate: '2020-12-12',
- timeStats: {
- humanTimeEstimate: '1w',
- },
+ humanTimeEstimate: '1w',
};
const findMilestone = () => wrapper.find('[data-testid="issuable-milestone"]');
@@ -56,7 +54,7 @@ describe('IssuesListApp component', () => {
expect(milestone.text()).toBe(issue.milestone.title);
expect(milestone.find(GlIcon).props('name')).toBe('clock');
- expect(milestone.find(GlLink).attributes('href')).toBe(issue.milestone.webUrl);
+ expect(milestone.find(GlLink).attributes('href')).toBe(issue.milestone.webPath);
});
describe.each`
@@ -102,7 +100,7 @@ describe('IssuesListApp component', () => {
const timeEstimate = wrapper.find('[data-testid="time-estimate"]');
- expect(timeEstimate.text()).toBe(issue.timeStats.humanTimeEstimate);
+ expect(timeEstimate.text()).toBe(issue.humanTimeEstimate);
expect(timeEstimate.attributes('title')).toBe('Estimate');
expect(timeEstimate.find(GlIcon).props('name')).toBe('timer');
});
diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues_list/components/issues_list_app_spec.js
index d78a436c618..a3ac57ee1bb 100644
--- a/spec/frontend/issues_list/components/issues_list_app_spec.js
+++ b/spec/frontend/issues_list/components/issues_list_app_spec.js
@@ -1,9 +1,19 @@
import { GlButton, GlEmptyState, GlLink } from '@gitlab/ui';
-import { mount, shallowMount } from '@vue/test-utils';
+import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
+import { cloneDeep } from 'lodash';
+import { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
+import createMockApollo from 'helpers/mock_apollo_helper';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
-import { apiParams, filteredTokens, locationSearch, urlParams } from 'jest/issues_list/mock_data';
+import {
+ getIssuesQueryResponse,
+ filteredTokens,
+ locationSearch,
+ urlParams,
+} from 'jest/issues_list/mock_data';
import createFlash from '~/flash';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
@@ -14,10 +24,7 @@ import {
apiSortParams,
CREATED_DESC,
DUE_DATE_OVERDUE,
- PAGE_SIZE,
- PAGE_SIZE_MANUAL,
PARAM_DUE_DATE,
- RELATIVE_POSITION_DESC,
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_AUTHOR,
TOKEN_TYPE_CONFIDENTIAL,
@@ -32,20 +39,26 @@ import {
import eventHub from '~/issues_list/eventhub';
import { getSortOptions } from '~/issues_list/utils';
import axios from '~/lib/utils/axios_utils';
+import { scrollUp } from '~/lib/utils/scroll_utils';
import { setUrlParams } from '~/lib/utils/url_utility';
jest.mock('~/flash');
+jest.mock('~/lib/utils/scroll_utils', () => ({
+ scrollUp: jest.fn().mockName('scrollUpMock'),
+}));
describe('IssuesListApp component', () => {
let axiosMock;
let wrapper;
+ const localVue = createLocalVue();
+ localVue.use(VueApollo);
+
const defaultProvide = {
autocompleteUsersPath: 'autocomplete/users/path',
calendarPath: 'calendar/path',
canBulkUpdate: false,
emptyStateSvgPath: 'empty-state.svg',
- endpoint: 'api/endpoint',
exportCsvPath: 'export/csv/path',
hasBlockedIssuesFeature: true,
hasIssueWeightsFeature: true,
@@ -61,21 +74,13 @@ describe('IssuesListApp component', () => {
signInPath: 'sign/in/path',
};
- const state = 'opened';
- const xPage = 1;
- const xTotal = 25;
- const tabCounts = {
- opened: xTotal,
- closed: undefined,
- all: undefined,
- };
- const fetchIssuesResponse = {
- data: [],
- headers: {
- 'x-page': xPage,
- 'x-total': xTotal,
- },
- };
+ let defaultQueryResponse = getIssuesQueryResponse;
+ if (IS_EE) {
+ defaultQueryResponse = cloneDeep(getIssuesQueryResponse);
+ defaultQueryResponse.data.project.issues.nodes[0].blockedByCount = 1;
+ defaultQueryResponse.data.project.issues.nodes[0].healthStatus = null;
+ defaultQueryResponse.data.project.issues.nodes[0].weight = 5;
+ }
const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons);
const findIssuableByEmail = () => wrapper.findComponent(IssuableByEmail);
@@ -86,19 +91,26 @@ describe('IssuesListApp component', () => {
const findGlLink = () => wrapper.findComponent(GlLink);
const findIssuableList = () => wrapper.findComponent(IssuableList);
- const mountComponent = ({ provide = {}, mountFn = shallowMount } = {}) =>
- mountFn(IssuesListApp, {
+ const mountComponent = ({
+ provide = {},
+ response = defaultQueryResponse,
+ mountFn = shallowMount,
+ } = {}) => {
+ const requestHandlers = [[getIssuesQuery, jest.fn().mockResolvedValue(response)]];
+ const apolloProvider = createMockApollo(requestHandlers);
+
+ return mountFn(IssuesListApp, {
+ localVue,
+ apolloProvider,
provide: {
...defaultProvide,
...provide,
},
});
+ };
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
- axiosMock
- .onGet(defaultProvide.endpoint)
- .reply(200, fetchIssuesResponse.data, fetchIssuesResponse.headers);
});
afterEach(() => {
@@ -108,28 +120,37 @@ describe('IssuesListApp component', () => {
});
describe('IssuableList', () => {
- beforeEach(async () => {
+ beforeEach(() => {
wrapper = mountComponent();
- await waitForPromises();
+ jest.runOnlyPendingTimers();
});
it('renders', () => {
expect(findIssuableList().props()).toMatchObject({
namespace: defaultProvide.projectPath,
recentSearchesStorageKey: 'issues',
- searchInputPlaceholder: 'Search or filter results…',
+ searchInputPlaceholder: IssuesListApp.i18n.searchPlaceholder,
sortOptions: getSortOptions(true, true),
initialSortBy: CREATED_DESC,
+ issuables: getIssuesQueryResponse.data.project.issues.nodes,
tabs: IssuableListTabs,
currentTab: IssuableStates.Opened,
- tabCounts,
- showPaginationControls: false,
- issuables: [],
- totalItems: xTotal,
- currentPage: xPage,
- previousPage: xPage - 1,
- nextPage: xPage + 1,
- urlParams: { page: xPage, state },
+ tabCounts: {
+ opened: 1,
+ closed: undefined,
+ all: undefined,
+ },
+ issuablesLoading: false,
+ isManualOrdering: false,
+ showBulkEditSidebar: false,
+ showPaginationControls: true,
+ useKeysetPagination: true,
+ hasPreviousPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasPreviousPage,
+ hasNextPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasNextPage,
+ urlParams: {
+ state: IssuableStates.Opened,
+ ...urlSortParams[CREATED_DESC],
+ },
});
});
});
@@ -157,9 +178,9 @@ describe('IssuesListApp component', () => {
describe('csv import/export component', () => {
describe('when user is signed in', () => {
- it('renders', async () => {
- const search = '?page=1&search=refactor&state=opened&sort=created_date';
+ const search = '?search=refactor&state=opened&sort=created_date';
+ beforeEach(() => {
global.jsdom.reconfigure({ url: `${TEST_HOST}${search}` });
wrapper = mountComponent({
@@ -167,11 +188,13 @@ describe('IssuesListApp component', () => {
mountFn: mount,
});
- await waitForPromises();
+ jest.runOnlyPendingTimers();
+ });
+ it('renders', () => {
expect(findCsvImportExportButtons().props()).toMatchObject({
exportCsvPath: `${defaultProvide.exportCsvPath}${search}`,
- issuableCount: xTotal,
+ issuableCount: 1,
});
});
});
@@ -238,18 +261,6 @@ describe('IssuesListApp component', () => {
});
});
- describe('page', () => {
- it('is set from the url params', () => {
- const page = 5;
-
- global.jsdom.reconfigure({ url: setUrlParams({ page }, TEST_HOST) });
-
- wrapper = mountComponent();
-
- expect(findIssuableList().props('currentPage')).toBe(page);
- });
- });
-
describe('search', () => {
it('is set from the url params', () => {
global.jsdom.reconfigure({ url: `${TEST_HOST}${locationSearch}` });
@@ -326,12 +337,10 @@ describe('IssuesListApp component', () => {
describe('empty states', () => {
describe('when there are issues', () => {
describe('when search returns no results', () => {
- beforeEach(async () => {
+ beforeEach(() => {
global.jsdom.reconfigure({ url: `${TEST_HOST}?search=no+results` });
wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
-
- await waitForPromises();
});
it('shows empty state', () => {
@@ -344,10 +353,8 @@ describe('IssuesListApp component', () => {
});
describe('when "Open" tab has no issues', () => {
- beforeEach(async () => {
+ beforeEach(() => {
wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
-
- await waitForPromises();
});
it('shows empty state', () => {
@@ -360,14 +367,12 @@ describe('IssuesListApp component', () => {
});
describe('when "Closed" tab has no issues', () => {
- beforeEach(async () => {
+ beforeEach(() => {
global.jsdom.reconfigure({
url: setUrlParams({ state: IssuableStates.Closed }, TEST_HOST),
});
wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
-
- await waitForPromises();
});
it('shows empty state', () => {
@@ -555,98 +560,70 @@ describe('IssuesListApp component', () => {
describe('events', () => {
describe('when "click-tab" event is emitted by IssuableList', () => {
beforeEach(() => {
- axiosMock.onGet(defaultProvide.endpoint).reply(200, fetchIssuesResponse.data, {
- 'x-page': 2,
- 'x-total': xTotal,
- });
-
wrapper = mountComponent();
findIssuableList().vm.$emit('click-tab', IssuableStates.Closed);
});
- it('makes API call to filter the list by the new state and resets the page to 1', () => {
- expect(axiosMock.history.get[1].params).toMatchObject({
- page: 1,
- state: IssuableStates.Closed,
- });
+ it('updates to the new tab', () => {
+ expect(findIssuableList().props('currentTab')).toBe(IssuableStates.Closed);
});
});
- describe('when "page-change" event is emitted by IssuableList', () => {
- const data = [{ id: 10, title: 'title', state }];
- const page = 2;
- const totalItems = 21;
-
- beforeEach(async () => {
- axiosMock.onGet(defaultProvide.endpoint).reply(200, data, {
- 'x-page': page,
- 'x-total': totalItems,
- });
-
- wrapper = mountComponent();
-
- findIssuableList().vm.$emit('page-change', page);
-
- await waitForPromises();
- });
+ describe.each(['next-page', 'previous-page'])(
+ 'when "%s" event is emitted by IssuableList',
+ (event) => {
+ beforeEach(() => {
+ wrapper = mountComponent();
- it('fetches issues with expected params', () => {
- expect(axiosMock.history.get[1].params).toMatchObject({
- page,
- per_page: PAGE_SIZE,
- state,
- with_labels_details: true,
+ findIssuableList().vm.$emit(event);
});
- });
- it('updates IssuableList with response data', () => {
- expect(findIssuableList().props()).toMatchObject({
- issuables: data,
- totalItems,
- currentPage: page,
- previousPage: page - 1,
- nextPage: page + 1,
- urlParams: { page, state },
+ it('scrolls to the top', () => {
+ expect(scrollUp).toHaveBeenCalled();
});
- });
- });
+ },
+ );
describe('when "reorder" event is emitted by IssuableList', () => {
- const issueOne = { id: 1, iid: 101, title: 'Issue one' };
- const issueTwo = { id: 2, iid: 102, title: 'Issue two' };
- const issueThree = { id: 3, iid: 103, title: 'Issue three' };
- const issueFour = { id: 4, iid: 104, title: 'Issue four' };
- const issues = [issueOne, issueTwo, issueThree, issueFour];
-
- beforeEach(async () => {
- axiosMock.onGet(defaultProvide.endpoint).reply(200, issues, fetchIssuesResponse.headers);
- wrapper = mountComponent();
- await waitForPromises();
- });
-
- describe('when successful', () => {
- describe.each`
- description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId
- ${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id}
- ${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id}
- ${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id}
- ${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null}
- `(
- 'when moving issue $description',
- ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => {
- it('makes API call to reorder the issue', async () => {
- findIssuableList().vm.$emit('reorder', { oldIndex, newIndex });
-
- await waitForPromises();
-
- expect(axiosMock.history.put[0]).toMatchObject({
- url: `${defaultProvide.issuesPath}/${issueToMove.iid}/reorder`,
- data: JSON.stringify({ move_before_id: moveBeforeId, move_after_id: moveAfterId }),
- });
- });
+ const issueOne = {
+ ...defaultQueryResponse.data.project.issues.nodes[0],
+ id: 'gid://gitlab/Issue/1',
+ iid: 101,
+ title: 'Issue one',
+ };
+ const issueTwo = {
+ ...defaultQueryResponse.data.project.issues.nodes[0],
+ id: 'gid://gitlab/Issue/2',
+ iid: 102,
+ title: 'Issue two',
+ };
+ const issueThree = {
+ ...defaultQueryResponse.data.project.issues.nodes[0],
+ id: 'gid://gitlab/Issue/3',
+ iid: 103,
+ title: 'Issue three',
+ };
+ const issueFour = {
+ ...defaultQueryResponse.data.project.issues.nodes[0],
+ id: 'gid://gitlab/Issue/4',
+ iid: 104,
+ title: 'Issue four',
+ };
+ const response = {
+ data: {
+ project: {
+ issues: {
+ ...defaultQueryResponse.data.project.issues,
+ nodes: [issueOne, issueTwo, issueThree, issueFour],
+ },
},
- );
+ },
+ };
+
+ beforeEach(() => {
+ wrapper = mountComponent({ response });
+ jest.runOnlyPendingTimers();
});
describe('when unsuccessful', () => {
@@ -664,21 +641,16 @@ describe('IssuesListApp component', () => {
describe('when "sort" event is emitted by IssuableList', () => {
it.each(Object.keys(apiSortParams))(
- 'fetches issues with correct params with payload `%s`',
+ 'updates to the new sort when payload is `%s`',
async (sortKey) => {
wrapper = mountComponent();
findIssuableList().vm.$emit('sort', sortKey);
- await waitForPromises();
+ jest.runOnlyPendingTimers();
+ await nextTick();
- expect(axiosMock.history.get[1].params).toEqual({
- page: xPage,
- per_page: sortKey === RELATIVE_POSITION_DESC ? PAGE_SIZE_MANUAL : PAGE_SIZE,
- state,
- with_labels_details: true,
- ...apiSortParams[sortKey],
- });
+ expect(findIssuableList().props('urlParams')).toMatchObject(urlSortParams[sortKey]);
},
);
});
@@ -687,13 +659,11 @@ describe('IssuesListApp component', () => {
beforeEach(() => {
wrapper = mountComponent();
jest.spyOn(eventHub, '$emit');
- });
- it('emits an "issuables:updateBulkEdit" event to the legacy bulk edit class', async () => {
findIssuableList().vm.$emit('update-legacy-bulk-edit');
+ });
- await waitForPromises();
-
+ it('emits an "issuables:updateBulkEdit" event to the legacy bulk edit class', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit');
});
});
@@ -705,10 +675,6 @@ describe('IssuesListApp component', () => {
findIssuableList().vm.$emit('filter', filteredTokens);
});
- it('makes an API call to search for issues with the search term', () => {
- expect(axiosMock.history.get[1].params).toMatchObject(apiParams);
- });
-
it('updates IssuableList with url params', () => {
expect(findIssuableList().props('urlParams')).toMatchObject(urlParams);
});
diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues_list/mock_data.js
index 99267fb6e31..6c669e02070 100644
--- a/spec/frontend/issues_list/mock_data.js
+++ b/spec/frontend/issues_list/mock_data.js
@@ -3,6 +3,73 @@ import {
OPERATOR_IS_NOT,
} from '~/vue_shared/components/filtered_search_bar/constants';
+export const getIssuesQueryResponse = {
+ data: {
+ project: {
+ issues: {
+ count: 1,
+ pageInfo: {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: 'startcursor',
+ endCursor: 'endcursor',
+ },
+ nodes: [
+ {
+ id: 'gid://gitlab/Issue/123456',
+ iid: '789',
+ closedAt: null,
+ confidential: false,
+ createdAt: '2021-05-22T04:08:01Z',
+ downvotes: 2,
+ dueDate: '2021-05-29',
+ humanTimeEstimate: null,
+ moved: false,
+ title: 'Issue title',
+ updatedAt: '2021-05-22T04:08:01Z',
+ upvotes: 3,
+ userDiscussionsCount: 4,
+ webUrl: 'project/-/issues/789',
+ assignees: {
+ nodes: [
+ {
+ id: 'gid://gitlab/User/234',
+ avatarUrl: 'avatar/url',
+ name: 'Marge Simpson',
+ username: 'msimpson',
+ webUrl: 'url/msimpson',
+ },
+ ],
+ },
+ author: {
+ id: 'gid://gitlab/User/456',
+ avatarUrl: 'avatar/url',
+ name: 'Homer Simpson',
+ username: 'hsimpson',
+ webUrl: 'url/hsimpson',
+ },
+ labels: {
+ nodes: [
+ {
+ id: 'gid://gitlab/ProjectLabel/456',
+ color: '#333',
+ title: 'Label title',
+ description: 'Label description',
+ },
+ ],
+ },
+ milestone: null,
+ taskCompletionStatus: {
+ completedCount: 1,
+ count: 2,
+ },
+ },
+ ],
+ },
+ },
+ },
+};
+
export const locationSearch = [
'?search=find+issues',
'author_username=homer',
diff --git a/spec/frontend/runner/components/runner_manual_setup_help_spec.js b/spec/frontend/runner/components/runner_manual_setup_help_spec.js
index ca5c88f6e28..add595d784e 100644
--- a/spec/frontend/runner/components/runner_manual_setup_help_spec.js
+++ b/spec/frontend/runner/components/runner_manual_setup_help_spec.js
@@ -1,8 +1,11 @@
import { GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
import { TEST_HOST } from 'helpers/test_constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue';
+import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue';
+import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
@@ -14,6 +17,8 @@ describe('RunnerManualSetupHelp', () => {
let originalGon;
const findRunnerInstructions = () => wrapper.findComponent(RunnerInstructions);
+ const findRunnerRegistrationTokenReset = () =>
+ wrapper.findComponent(RunnerRegistrationTokenReset);
const findClipboardButtons = () => wrapper.findAllComponents(ClipboardButton);
const findRunnerHelpTitle = () => wrapper.findByTestId('runner-help-title');
const findCoordinatorUrl = () => wrapper.findByTestId('coordinator-url');
@@ -28,6 +33,7 @@ describe('RunnerManualSetupHelp', () => {
},
propsData: {
registrationToken: mockRegistrationToken,
+ type: INSTANCE_TYPE,
...props,
},
stubs: {
@@ -54,16 +60,26 @@ describe('RunnerManualSetupHelp', () => {
wrapper.destroy();
});
- it('Title contains the default runner type', () => {
+ it('Title contains the shared runner type', () => {
+ createComponent({ props: { type: INSTANCE_TYPE } });
+
expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a shared runner manually');
});
it('Title contains the group runner type', () => {
- createComponent({ props: { typeName: 'group' } });
+ createComponent({ props: { type: GROUP_TYPE } });
expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a group runner manually');
});
+ it('Title contains the specific runner type', () => {
+ createComponent({ props: { type: PROJECT_TYPE } });
+
+ expect(findRunnerHelpTitle().text()).toMatchInterpolatedText(
+ 'Set up a specific runner manually',
+ );
+ });
+
it('Runner Install Page link', () => {
expect(findRunnerHelpLink().attributes('href')).toBe(mockRunnerInstallHelpPage);
});
@@ -73,12 +89,27 @@ describe('RunnerManualSetupHelp', () => {
expect(findClipboardButtons().at(0).props('text')).toBe(TEST_HOST);
});
+ it('Displays the runner instructions', () => {
+ expect(findRunnerInstructions().exists()).toBe(true);
+ });
+
it('Displays the registration token', () => {
expect(findRegistrationToken().text()).toBe(mockRegistrationToken);
expect(findClipboardButtons().at(1).props('text')).toBe(mockRegistrationToken);
});
- it('Displays the runner instructions', () => {
- expect(findRunnerInstructions().exists()).toBe(true);
+ it('Displays the runner registration token reset button', () => {
+ expect(findRunnerRegistrationTokenReset().exists()).toBe(true);
+ });
+
+ it('Replaces the runner reset button', async () => {
+ const mockNewRegistrationToken = 'NEW_MOCK_REGISTRATION_TOKEN';
+
+ findRunnerRegistrationTokenReset().vm.$emit('tokenReset', mockNewRegistrationToken);
+
+ await nextTick();
+
+ expect(findRegistrationToken().text()).toBe(mockNewRegistrationToken);
+ expect(findClipboardButtons().at(1).props('text')).toBe(mockNewRegistrationToken);
});
});
diff --git a/spec/frontend/runner/components/runner_registration_token_reset_spec.js b/spec/frontend/runner/components/runner_registration_token_reset_spec.js
new file mode 100644
index 00000000000..fa5751b380f
--- /dev/null
+++ b/spec/frontend/runner/components/runner_registration_token_reset_spec.js
@@ -0,0 +1,155 @@
+import { GlButton } from '@gitlab/ui';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash, { FLASH_TYPES } from '~/flash';
+import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue';
+import { INSTANCE_TYPE } from '~/runner/constants';
+import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql';
+
+jest.mock('~/flash');
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+const mockNewToken = 'NEW_TOKEN';
+
+describe('RunnerRegistrationTokenReset', () => {
+ let wrapper;
+ let runnersRegistrationTokenResetMutationHandler;
+
+ const findButton = () => wrapper.findComponent(GlButton);
+
+ const createComponent = () => {
+ wrapper = shallowMount(RunnerRegistrationTokenReset, {
+ localVue,
+ propsData: {
+ type: INSTANCE_TYPE,
+ },
+ apolloProvider: createMockApollo([
+ [runnersRegistrationTokenResetMutation, runnersRegistrationTokenResetMutationHandler],
+ ]),
+ });
+ };
+
+ beforeEach(() => {
+ runnersRegistrationTokenResetMutationHandler = jest.fn().mockResolvedValue({
+ data: {
+ runnersRegistrationTokenReset: {
+ token: mockNewToken,
+ errors: [],
+ },
+ },
+ });
+
+ createComponent();
+
+ jest.spyOn(window, 'confirm');
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('Displays reset button', () => {
+ expect(findButton().exists()).toBe(true);
+ });
+
+ describe('On click and confirmation', () => {
+ beforeEach(async () => {
+ window.confirm.mockReturnValueOnce(true);
+ await findButton().vm.$emit('click');
+ });
+
+ it('resets token', () => {
+ expect(runnersRegistrationTokenResetMutationHandler).toHaveBeenCalledTimes(1);
+ expect(runnersRegistrationTokenResetMutationHandler).toHaveBeenCalledWith({
+ input: { type: INSTANCE_TYPE },
+ });
+ });
+
+ it('emits result', () => {
+ expect(wrapper.emitted('tokenReset')).toHaveLength(1);
+ expect(wrapper.emitted('tokenReset')[0]).toEqual([mockNewToken]);
+ });
+
+ it('does not show a loading state', () => {
+ expect(findButton().props('loading')).toBe(false);
+ });
+
+ it('shows confirmation', () => {
+ expect(createFlash).toHaveBeenLastCalledWith({
+ message: expect.stringContaining('registration token generated'),
+ type: FLASH_TYPES.SUCCESS,
+ });
+ });
+ });
+
+ describe('On click without confirmation', () => {
+ beforeEach(async () => {
+ window.confirm.mockReturnValueOnce(false);
+ await findButton().vm.$emit('click');
+ });
+
+ it('does not reset token', () => {
+ expect(runnersRegistrationTokenResetMutationHandler).not.toHaveBeenCalled();
+ });
+
+ it('does not emit any result', () => {
+ expect(wrapper.emitted('tokenReset')).toBeUndefined();
+ });
+
+ it('does not show a loading state', () => {
+ expect(findButton().props('loading')).toBe(false);
+ });
+
+ it('does not shows confirmation', () => {
+ expect(createFlash).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('On error', () => {
+ it('On network error, error message is shown', async () => {
+ runnersRegistrationTokenResetMutationHandler.mockRejectedValueOnce(
+ new Error('Something went wrong'),
+ );
+
+ window.confirm.mockReturnValueOnce(true);
+ await findButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenLastCalledWith({
+ message: 'Network error: Something went wrong',
+ });
+ });
+
+ it('On validation error, error message is shown', async () => {
+ runnersRegistrationTokenResetMutationHandler.mockResolvedValue({
+ data: {
+ runnersRegistrationTokenReset: {
+ token: null,
+ errors: ['Token reset failed'],
+ },
+ },
+ });
+
+ window.confirm.mockReturnValueOnce(true);
+ await findButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenLastCalledWith({
+ message: 'Token reset failed',
+ });
+ });
+ });
+
+ describe('Immediately after click', () => {
+ it('shows loading state', async () => {
+ window.confirm.mockReturnValue(true);
+ await findButton().vm.$emit('click');
+
+ expect(findButton().props('loading')).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
index f50eafdbc52..951b050495c 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
@@ -46,6 +46,7 @@ function createComponent(options = {}) {
active = false,
stubs = defaultStubs,
data = {},
+ listeners = {},
} = options;
return mount(AuthorToken, {
propsData: {
@@ -62,6 +63,7 @@ function createComponent(options = {}) {
return { ...data };
},
stubs,
+ listeners,
});
}
@@ -258,6 +260,18 @@ describe('AuthorToken', () => {
expect(suggestions.at(0).text()).toBe(DEFAULT_LABEL_ANY.text);
});
+ it('emits listeners in the base-token', () => {
+ const mockInput = jest.fn();
+ wrapper = createComponent({
+ listeners: {
+ input: mockInput,
+ },
+ });
+ wrapper.findComponent(BaseToken).vm.$emit('input', [{ data: 'mockData', operator: '=' }]);
+
+ expect(mockInput).toHaveBeenLastCalledWith([{ data: 'mockData', operator: '=' }]);
+ });
+
describe('when loading', () => {
beforeEach(() => {
wrapper = createComponent({
@@ -276,6 +290,14 @@ describe('AuthorToken', () => {
expect(firstSuggestion).toContain('Administrator');
expect(firstSuggestion).toContain('@root');
});
+
+ it('does not show current user while searching', async () => {
+ wrapper.findComponent(BaseToken).vm.handleInput({ data: 'foo' });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.findComponent(GlFilteredSearchSuggestion).exists()).toBe(false);
+ });
});
});
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
index 602864f4fa5..89c5cedc9b8 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
@@ -46,12 +46,11 @@ const defaultSlots = {
};
const mockProps = {
- tokenConfig: mockLabelToken,
- tokenValue: { data: '' },
- tokenActive: false,
- tokensListLoading: false,
+ config: mockLabelToken,
+ value: { data: '' },
+ active: false,
tokenValues: [],
- fnActiveTokenValue: jest.fn(),
+ tokensListLoading: false,
defaultTokenValues: DEFAULT_LABELS,
recentTokenValuesStorageKey: mockStorageKey,
fnCurrentTokenValue: jest.fn(),
@@ -83,7 +82,7 @@ describe('BaseToken', () => {
wrapper = createComponent({
props: {
...mockProps,
- tokenValue: { data: `"${mockRegularLabel.title}"` },
+ value: { data: `"${mockRegularLabel.title}"` },
tokenValues: mockLabels,
},
});
@@ -112,17 +111,17 @@ describe('BaseToken', () => {
describe('activeTokenValue', () => {
it('calls `fnActiveTokenValue` when it is provided', async () => {
+ const mockFnActiveTokenValue = jest.fn();
+
wrapper.setProps({
+ fnActiveTokenValue: mockFnActiveTokenValue,
fnCurrentTokenValue: undefined,
});
await wrapper.vm.$nextTick();
- // We're disabling lint to trigger computed prop execution for this test.
- // eslint-disable-next-line no-unused-vars
- const { activeTokenValue } = wrapper.vm;
-
- expect(wrapper.vm.fnActiveTokenValue).toHaveBeenCalledWith(
+ expect(mockFnActiveTokenValue).toHaveBeenCalledTimes(1);
+ expect(mockFnActiveTokenValue).toHaveBeenCalledWith(
mockLabels,
`"${mockRegularLabel.title.toLowerCase()}"`,
);
@@ -131,15 +130,15 @@ describe('BaseToken', () => {
});
describe('watch', () => {
- describe('tokenActive', () => {
+ describe('active', () => {
let wrapperWithTokenActive;
beforeEach(() => {
wrapperWithTokenActive = createComponent({
props: {
...mockProps,
- tokenActive: true,
- tokenValue: { data: `"${mockRegularLabel.title}"` },
+ value: { data: `"${mockRegularLabel.title}"` },
+ active: true,
},
});
});
@@ -150,7 +149,7 @@ describe('BaseToken', () => {
it('emits `fetch-token-values` event on the component when value of this prop is changed to false and `tokenValues` array is empty', async () => {
wrapperWithTokenActive.setProps({
- tokenActive: false,
+ active: false,
});
await wrapperWithTokenActive.vm.$nextTick();
@@ -238,7 +237,7 @@ describe('BaseToken', () => {
jest.runAllTimers();
expect(wrapperWithNoStubs.emitted('fetch-token-values')).toBeTruthy();
- expect(wrapperWithNoStubs.emitted('fetch-token-values')[1]).toEqual(['foo']);
+ expect(wrapperWithNoStubs.emitted('fetch-token-values')[2]).toEqual(['foo']);
});
});
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
index dd1c61b92b8..cc40ff96b65 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
@@ -40,6 +40,7 @@ function createComponent(options = {}) {
value = { data: '' },
active = false,
stubs = defaultStubs,
+ listeners = {},
} = options;
return mount(LabelToken, {
propsData: {
@@ -53,6 +54,7 @@ function createComponent(options = {}) {
suggestionsListClass: 'custom-class',
},
stubs,
+ listeners,
});
}
@@ -206,7 +208,7 @@ describe('LabelToken', () => {
expect(wrapper.find(GlDropdownDivider).exists()).toBe(false);
});
- it('renders `DEFAULT_LABELS` as default suggestions', async () => {
+ it('renders `DEFAULT_LABELS` as default suggestions', () => {
wrapper = createComponent({
active: true,
config: { ...mockLabelToken },
@@ -215,7 +217,6 @@ describe('LabelToken', () => {
const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
const suggestionsSegment = tokenSegments.at(2);
suggestionsSegment.vm.$emit('activate');
- await wrapper.vm.$nextTick();
const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
@@ -224,5 +225,17 @@ describe('LabelToken', () => {
expect(suggestions.at(index).text()).toBe(label.text);
});
});
+
+ it('emits listeners in the base-token', () => {
+ const mockInput = jest.fn();
+ wrapper = createComponent({
+ listeners: {
+ input: mockInput,
+ },
+ });
+ wrapper.findComponent(BaseToken).vm.$emit('input', [{ data: 'mockData', operator: '=' }]);
+
+ expect(mockInput).toHaveBeenLastCalledWith([{ data: 'mockData', operator: '=' }]);
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js
new file mode 100644
index 00000000000..0a42d389b67
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js
@@ -0,0 +1,91 @@
+import { GlIcon, GlButton } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+
+import DropdownButton from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue';
+
+import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
+
+import { mockConfig } from './mock_data';
+
+let store;
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const createComponent = (initialState = mockConfig) => {
+ store = new Vuex.Store(labelSelectModule());
+
+ store.dispatch('setInitialState', initialState);
+
+ return shallowMount(DropdownButton, {
+ localVue,
+ store,
+ });
+};
+
+describe('DropdownButton', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findDropdownButton = () => wrapper.find(GlButton);
+ const findDropdownText = () => wrapper.find('.dropdown-toggle-text');
+ const findDropdownIcon = () => wrapper.find(GlIcon);
+
+ describe('methods', () => {
+ describe('handleButtonClick', () => {
+ it.each`
+ variant | expectPropagationStopped
+ ${'standalone'} | ${true}
+ ${'embedded'} | ${false}
+ `(
+ 'toggles dropdown content and handles event propagation when `state.variant` is "$variant"',
+ ({ variant, expectPropagationStopped }) => {
+ const event = { stopPropagation: jest.fn() };
+
+ wrapper = createComponent({ ...mockConfig, variant });
+
+ findDropdownButton().vm.$emit('click', event);
+
+ expect(store.state.showDropdownContents).toBe(true);
+ expect(event.stopPropagation).toHaveBeenCalledTimes(expectPropagationStopped ? 1 : 0);
+ },
+ );
+ });
+ });
+
+ describe('template', () => {
+ it('renders component container element', () => {
+ expect(wrapper.find(GlButton).element).toBe(wrapper.element);
+ });
+
+ it('renders default button text element', () => {
+ const dropdownTextEl = findDropdownText();
+
+ expect(dropdownTextEl.exists()).toBe(true);
+ expect(dropdownTextEl.text()).toBe('Label');
+ });
+
+ it('renders provided button text element', () => {
+ store.state.dropdownButtonText = 'Custom label';
+ const dropdownTextEl = findDropdownText();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(dropdownTextEl.text()).toBe('Custom label');
+ });
+ });
+
+ it('renders chevron icon element', () => {
+ const iconEl = findDropdownIcon();
+
+ expect(iconEl.exists()).toBe(true);
+ expect(iconEl.props('name')).toBe('chevron-down');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js
new file mode 100644
index 00000000000..46a11bc28d8
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js
@@ -0,0 +1,173 @@
+import { GlLoadingIcon, GlLink } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+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 createFlash from '~/flash';
+import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue';
+import createLabelMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql';
+import { mockSuggestedColors, createLabelSuccessfulResponse } from './mock_data';
+
+jest.mock('~/flash');
+
+const colors = Object.keys(mockSuggestedColors);
+
+const localVue = createLocalVue();
+Vue.use(VueApollo);
+
+const userRecoverableError = {
+ ...createLabelSuccessfulResponse,
+ errors: ['Houston, we have a problem'],
+};
+
+const createLabelSuccessHandler = jest.fn().mockResolvedValue(createLabelSuccessfulResponse);
+const createLabelUserRecoverableErrorHandler = jest.fn().mockResolvedValue(userRecoverableError);
+const createLabelErrorHandler = jest.fn().mockRejectedValue('Houston, we have a problem');
+
+describe('DropdownContentsCreateView', () => {
+ let wrapper;
+
+ const findAllColors = () => wrapper.findAllComponents(GlLink);
+ const findSelectedColor = () => wrapper.find('[data-testid="selected-color"]');
+ const findSelectedColorText = () => wrapper.find('[data-testid="selected-color-text"]');
+ const findCreateButton = () => wrapper.find('[data-testid="create-button"]');
+ const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]');
+ const findLabelTitleInput = () => wrapper.find('[data-testid="label-title-input"]');
+
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+
+ const fillLabelAttributes = () => {
+ findLabelTitleInput().vm.$emit('input', 'Test title');
+ findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
+ };
+
+ const createComponent = ({ mutationHandler = createLabelSuccessHandler } = {}) => {
+ const mockApollo = createMockApollo([[createLabelMutation, mutationHandler]]);
+
+ wrapper = shallowMount(DropdownContentsCreateView, {
+ localVue,
+ apolloProvider: mockApollo,
+ });
+ };
+
+ beforeEach(() => {
+ gon.suggested_label_colors = mockSuggestedColors;
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders a palette of 21 colors', () => {
+ createComponent();
+ expect(findAllColors()).toHaveLength(21);
+ });
+
+ it('selects a color after clicking on colored block', async () => {
+ createComponent();
+ expect(findSelectedColor().attributes('style')).toBeUndefined();
+
+ findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
+ await nextTick();
+
+ expect(findSelectedColor().attributes('style')).toBe('background-color: rgb(0, 153, 102);');
+ });
+
+ it('shows correct color hex code after selecting a color', async () => {
+ createComponent();
+ expect(findSelectedColorText().attributes('value')).toBe('');
+
+ findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
+ await nextTick();
+
+ expect(findSelectedColorText().attributes('value')).toBe(colors[0]);
+ });
+
+ it('disables a Create button if label title is not set', async () => {
+ createComponent();
+ findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
+ await nextTick();
+
+ expect(findCreateButton().props('disabled')).toBe(true);
+ });
+
+ it('disables a Create button if color is not set', async () => {
+ createComponent();
+ findLabelTitleInput().vm.$emit('input', 'Test title');
+ await nextTick();
+
+ expect(findCreateButton().props('disabled')).toBe(true);
+ });
+
+ it('does not render a loader spinner', () => {
+ createComponent();
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it('emits a `hideCreateView` event on Cancel button click', () => {
+ createComponent();
+ findCancelButton().vm.$emit('click');
+
+ expect(wrapper.emitted('hideCreateView')).toHaveLength(1);
+ });
+
+ describe('when label title and selected color are set', () => {
+ beforeEach(() => {
+ createComponent();
+ fillLabelAttributes();
+ });
+
+ it('enables a Create button', () => {
+ expect(findCreateButton().props('disabled')).toBe(false);
+ });
+
+ it('calls a mutation with correct parameters on Create button click', () => {
+ findCreateButton().vm.$emit('click');
+ expect(createLabelSuccessHandler).toHaveBeenCalledWith({
+ color: '#009966',
+ projectPath: '',
+ title: 'Test title',
+ });
+ });
+
+ it('renders a loader spinner after Create button click', async () => {
+ findCreateButton().vm.$emit('click');
+ await nextTick();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ it('does not loader spinner after mutation is resolved', async () => {
+ findCreateButton().vm.$emit('click');
+ await nextTick();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ await waitForPromises();
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ it('calls createFlash is mutation has a user-recoverable error', async () => {
+ createComponent({ mutationHandler: createLabelUserRecoverableErrorHandler });
+ fillLabelAttributes();
+ await nextTick();
+
+ findCreateButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalled();
+ });
+
+ it('calls createFlash is mutation was rejected', async () => {
+ createComponent({ mutationHandler: createLabelErrorHandler });
+ fillLabelAttributes();
+ await nextTick();
+
+ findCreateButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js
new file mode 100644
index 00000000000..51301387c99
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js
@@ -0,0 +1,357 @@
+import { GlIntersectionObserver, GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
+import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue';
+import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue';
+
+import * as actions from '~/vue_shared/components/sidebar/labels_select_widget/store/actions';
+import * as getters from '~/vue_shared/components/sidebar/labels_select_widget/store/getters';
+import mutations from '~/vue_shared/components/sidebar/labels_select_widget/store/mutations';
+import defaultState from '~/vue_shared/components/sidebar/labels_select_widget/store/state';
+
+import { mockConfig, mockLabels, mockRegularLabel } from './mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('DropdownContentsLabelsView', () => {
+ let wrapper;
+
+ const createComponent = (initialState = mockConfig) => {
+ const store = new Vuex.Store({
+ getters,
+ mutations,
+ state: {
+ ...defaultState(),
+ footerCreateLabelTitle: 'Create label',
+ footerManageLabelTitle: 'Manage labels',
+ },
+ actions: {
+ ...actions,
+ fetchLabels: jest.fn(),
+ },
+ });
+
+ store.dispatch('setInitialState', initialState);
+ store.dispatch('receiveLabelsSuccess', mockLabels);
+
+ wrapper = shallowMount(DropdownContentsLabelsView, {
+ localVue,
+ store,
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findDropdownContent = () => wrapper.find('[data-testid="dropdown-content"]');
+ const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]');
+ const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
+
+ describe('computed', () => {
+ describe('visibleLabels', () => {
+ it('returns matching labels filtered with `searchKey`', () => {
+ wrapper.setData({
+ searchKey: 'bug',
+ });
+
+ expect(wrapper.vm.visibleLabels.length).toBe(1);
+ expect(wrapper.vm.visibleLabels[0].title).toBe('Bug');
+ });
+
+ it('returns matching labels with fuzzy filtering', () => {
+ wrapper.setData({
+ searchKey: 'bg',
+ });
+
+ expect(wrapper.vm.visibleLabels.length).toBe(2);
+ expect(wrapper.vm.visibleLabels[0].title).toBe('Bug');
+ expect(wrapper.vm.visibleLabels[1].title).toBe('Boog');
+ });
+
+ it('returns all labels when `searchKey` is empty', () => {
+ wrapper.setData({
+ searchKey: '',
+ });
+
+ expect(wrapper.vm.visibleLabels.length).toBe(mockLabels.length);
+ });
+ });
+
+ describe('showNoMatchingResultsMessage', () => {
+ it.each`
+ searchKey | labels | labelsDescription | returnValue
+ ${''} | ${[]} | ${'empty'} | ${false}
+ ${'bug'} | ${[]} | ${'empty'} | ${true}
+ ${''} | ${mockLabels} | ${'not empty'} | ${false}
+ ${'bug'} | ${mockLabels} | ${'not empty'} | ${false}
+ `(
+ 'returns $returnValue when searchKey is "$searchKey" and visibleLabels is $labelsDescription',
+ async ({ searchKey, labels, returnValue }) => {
+ wrapper.setData({
+ searchKey,
+ });
+
+ wrapper.vm.$store.dispatch('receiveLabelsSuccess', labels);
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.showNoMatchingResultsMessage).toBe(returnValue);
+ },
+ );
+ });
+ });
+
+ describe('methods', () => {
+ describe('isLabelSelected', () => {
+ it('returns true when provided `label` param is one of the selected labels', () => {
+ expect(wrapper.vm.isLabelSelected(mockRegularLabel)).toBe(true);
+ });
+
+ it('returns false when provided `label` param is not one of the selected labels', () => {
+ expect(wrapper.vm.isLabelSelected(mockLabels[2])).toBe(false);
+ });
+ });
+
+ describe('handleComponentAppear', () => {
+ it('calls `focusInput` on searchInput field', async () => {
+ wrapper.vm.$refs.searchInput.focusInput = jest.fn();
+
+ await wrapper.vm.handleComponentAppear();
+
+ expect(wrapper.vm.$refs.searchInput.focusInput).toHaveBeenCalled();
+ });
+ });
+
+ describe('handleComponentDisappear', () => {
+ it('calls action `receiveLabelsSuccess` with empty array', () => {
+ jest.spyOn(wrapper.vm, 'receiveLabelsSuccess');
+
+ wrapper.vm.handleComponentDisappear();
+
+ expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]);
+ });
+ });
+
+ describe('handleCreateLabelClick', () => {
+ it('calls actions `receiveLabelsSuccess` with empty array and `toggleDropdownContentsCreateView`', () => {
+ jest.spyOn(wrapper.vm, 'receiveLabelsSuccess');
+ jest.spyOn(wrapper.vm, 'toggleDropdownContentsCreateView');
+
+ wrapper.vm.handleCreateLabelClick();
+
+ expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]);
+ expect(wrapper.vm.toggleDropdownContentsCreateView).toHaveBeenCalled();
+ });
+ });
+
+ describe('handleKeyDown', () => {
+ it('decreases `currentHighlightItem` value by 1 when Up arrow key is pressed', () => {
+ wrapper.setData({
+ currentHighlightItem: 1,
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: UP_KEY_CODE,
+ });
+
+ expect(wrapper.vm.currentHighlightItem).toBe(0);
+ });
+
+ it('increases `currentHighlightItem` value by 1 when Down arrow key is pressed', () => {
+ wrapper.setData({
+ currentHighlightItem: 1,
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: DOWN_KEY_CODE,
+ });
+
+ expect(wrapper.vm.currentHighlightItem).toBe(2);
+ });
+
+ it('resets the search text when the Enter key is pressed', () => {
+ wrapper.setData({
+ currentHighlightItem: 1,
+ searchKey: 'bug',
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: ENTER_KEY_CODE,
+ });
+
+ expect(wrapper.vm.searchKey).toBe('');
+ });
+
+ it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => {
+ jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation();
+ wrapper.setData({
+ currentHighlightItem: 1,
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: ENTER_KEY_CODE,
+ });
+
+ expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([
+ {
+ ...mockLabels[1],
+ set: true,
+ },
+ ]);
+ });
+
+ it('calls action `toggleDropdownContents` when Esc key is pressed', () => {
+ jest.spyOn(wrapper.vm, 'toggleDropdownContents').mockImplementation();
+ wrapper.setData({
+ currentHighlightItem: 1,
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: ESC_KEY_CODE,
+ });
+
+ expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled();
+ });
+
+ it('calls action `scrollIntoViewIfNeeded` in next tick when any key is pressed', () => {
+ jest.spyOn(wrapper.vm, 'scrollIntoViewIfNeeded').mockImplementation();
+ wrapper.setData({
+ currentHighlightItem: 1,
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: DOWN_KEY_CODE,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.vm.scrollIntoViewIfNeeded).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('handleLabelClick', () => {
+ beforeEach(() => {
+ jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation();
+ });
+
+ it('calls action `updateSelectedLabels` with provided `label` param', () => {
+ wrapper.vm.handleLabelClick(mockRegularLabel);
+
+ expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([mockRegularLabel]);
+ });
+
+ it('calls action `toggleDropdownContents` when `state.allowMultiselect` is false', () => {
+ jest.spyOn(wrapper.vm, 'toggleDropdownContents');
+ wrapper.vm.$store.state.allowMultiselect = false;
+
+ wrapper.vm.handleLabelClick(mockRegularLabel);
+
+ expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders gl-intersection-observer as component root', () => {
+ expect(wrapper.find(GlIntersectionObserver).exists()).toBe(true);
+ });
+
+ it('renders gl-loading-icon component when `labelsFetchInProgress` prop is true', () => {
+ wrapper.vm.$store.dispatch('requestLabels');
+
+ return wrapper.vm.$nextTick(() => {
+ const loadingIconEl = findLoadingIcon();
+
+ expect(loadingIconEl.exists()).toBe(true);
+ expect(loadingIconEl.attributes('class')).toContain('labels-fetch-loading');
+ });
+ });
+
+ it('renders label search input element', () => {
+ const searchInputEl = wrapper.find(GlSearchBoxByType);
+
+ expect(searchInputEl.exists()).toBe(true);
+ });
+
+ it('renders label elements for all labels', () => {
+ expect(wrapper.findAll(LabelItem)).toHaveLength(mockLabels.length);
+ });
+
+ it('renders label element with `highlight` set to true when value of `currentHighlightItem` is more than -1', () => {
+ wrapper.setData({
+ currentHighlightItem: 0,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ const labelItemEl = findDropdownContent().find(LabelItem);
+
+ expect(labelItemEl.attributes('highlight')).toBe('true');
+ });
+ });
+
+ it('renders element containing "No matching results" when `searchKey` does not match with any label', () => {
+ wrapper.setData({
+ searchKey: 'abc',
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ const noMatchEl = findDropdownContent().find('li');
+
+ expect(noMatchEl.isVisible()).toBe(true);
+ expect(noMatchEl.text()).toContain('No matching results');
+ });
+ });
+
+ it('renders empty content while loading', () => {
+ wrapper.vm.$store.state.labelsFetchInProgress = true;
+
+ return wrapper.vm.$nextTick(() => {
+ const dropdownContent = findDropdownContent();
+ const loadingIcon = findLoadingIcon();
+
+ expect(dropdownContent.exists()).toBe(true);
+ expect(dropdownContent.isVisible()).toBe(true);
+ expect(loadingIcon.exists()).toBe(true);
+ expect(loadingIcon.isVisible()).toBe(true);
+ });
+ });
+
+ it('renders footer list items', () => {
+ const footerLinks = findDropdownFooter().findAll(GlLink);
+ const createLabelLink = footerLinks.at(0);
+ const manageLabelsLink = footerLinks.at(1);
+
+ expect(createLabelLink.exists()).toBe(true);
+ expect(createLabelLink.text()).toBe('Create label');
+ expect(manageLabelsLink.exists()).toBe(true);
+ expect(manageLabelsLink.text()).toBe('Manage labels');
+ });
+
+ it('does not render "Create label" footer link when `state.allowLabelCreate` is `false`', () => {
+ wrapper.vm.$store.state.allowLabelCreate = false;
+
+ return wrapper.vm.$nextTick(() => {
+ const createLabelLink = findDropdownFooter().findAll(GlLink).at(0);
+
+ expect(createLabelLink.text()).not.toBe('Create label');
+ });
+ });
+
+ it('does not render footer list items when `state.variant` is "standalone"', () => {
+ createComponent({ ...mockConfig, variant: 'standalone' });
+ expect(findDropdownFooter().exists()).toBe(false);
+ });
+
+ it('renders footer list items when `state.variant` is "embedded"', () => {
+ expect(findDropdownFooter().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js
new file mode 100644
index 00000000000..8273bbdf7a7
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js
@@ -0,0 +1,72 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+
+import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
+import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue';
+import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
+
+import { mockConfig } from './mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const createComponent = (initialState = mockConfig, defaultProps = {}) => {
+ const store = new Vuex.Store(labelsSelectModule());
+
+ store.dispatch('setInitialState', initialState);
+
+ return shallowMount(DropdownContents, {
+ propsData: {
+ ...defaultProps,
+ labelsCreateTitle: 'test',
+ },
+ localVue,
+ store,
+ });
+};
+
+describe('DropdownContent', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('computed', () => {
+ describe('dropdownContentsView', () => {
+ it('returns string "dropdown-contents-create-view" when `showDropdownContentsCreateView` prop is `true`', () => {
+ wrapper.vm.$store.dispatch('toggleDropdownContentsCreateView');
+
+ expect(wrapper.vm.dropdownContentsView).toBe('dropdown-contents-create-view');
+ });
+
+ it('returns string "dropdown-contents-labels-view" when `showDropdownContentsCreateView` prop is `false`', () => {
+ expect(wrapper.vm.dropdownContentsView).toBe('dropdown-contents-labels-view');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component container element with class `labels-select-dropdown-contents` and no styles', () => {
+ expect(wrapper.attributes('class')).toContain('labels-select-dropdown-contents');
+ expect(wrapper.attributes('style')).toBeUndefined();
+ });
+
+ describe('when `renderOnTop` is true', () => {
+ it.each`
+ variant | expected
+ ${DropdownVariant.Sidebar} | ${'bottom: 3rem'}
+ ${DropdownVariant.Standalone} | ${'bottom: 2rem'}
+ ${DropdownVariant.Embedded} | ${'bottom: 2rem'}
+ `('renders upward for $variant variant', ({ variant, expected }) => {
+ wrapper = createComponent({ ...mockConfig, variant }, { renderOnTop: true });
+
+ expect(wrapper.attributes('style')).toContain(expected);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js
new file mode 100644
index 00000000000..d2401a1f725
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js
@@ -0,0 +1,61 @@
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+
+import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue';
+
+import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
+
+import { mockConfig } from './mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const createComponent = (initialState = mockConfig) => {
+ const store = new Vuex.Store(labelsSelectModule());
+
+ store.dispatch('setInitialState', initialState);
+
+ return shallowMount(DropdownTitle, {
+ localVue,
+ store,
+ propsData: {
+ labelsSelectInProgress: false,
+ },
+ });
+};
+
+describe('DropdownTitle', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ it('renders component container element with string "Labels"', () => {
+ expect(wrapper.text()).toContain('Labels');
+ });
+
+ it('renders edit link', () => {
+ const editBtnEl = wrapper.find(GlButton);
+
+ expect(editBtnEl.exists()).toBe(true);
+ expect(editBtnEl.text()).toBe('Edit');
+ });
+
+ it('renders loading icon element when `labelsSelectInProgress` prop is true', () => {
+ wrapper.setProps({
+ labelsSelectInProgress: true,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(true);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js
new file mode 100644
index 00000000000..59f3268c000
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js
@@ -0,0 +1,88 @@
+import { GlLabel } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+
+import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue';
+
+import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
+
+import { mockConfig, mockRegularLabel, mockScopedLabel } from './mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('DropdownValue', () => {
+ let wrapper;
+
+ const createComponent = (initialState = {}, slots = {}) => {
+ const store = new Vuex.Store(labelsSelectModule());
+
+ store.dispatch('setInitialState', { ...mockConfig, ...initialState });
+
+ wrapper = shallowMount(DropdownValue, {
+ localVue,
+ store,
+ slots,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('methods', () => {
+ describe('labelFilterUrl', () => {
+ it('returns a label filter URL based on provided label param', () => {
+ createComponent();
+
+ expect(wrapper.vm.labelFilterUrl(mockRegularLabel)).toBe(
+ '/gitlab-org/my-project/issues?label_name[]=Foo%20Label',
+ );
+ });
+ });
+
+ describe('scopedLabel', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('returns `true` when provided label param is a scoped label', () => {
+ expect(wrapper.vm.scopedLabel(mockScopedLabel)).toBe(true);
+ });
+
+ it('returns `false` when provided label param is a regular label', () => {
+ expect(wrapper.vm.scopedLabel(mockRegularLabel)).toBe(false);
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders class `has-labels` on component container element when `selectedLabels` is not empty', () => {
+ createComponent();
+
+ expect(wrapper.attributes('class')).toContain('has-labels');
+ });
+
+ it('renders element containing `None` when `selectedLabels` is empty', () => {
+ createComponent(
+ {
+ selectedLabels: [],
+ },
+ {
+ default: 'None',
+ },
+ );
+ const noneEl = wrapper.find('span.text-secondary');
+
+ expect(noneEl.exists()).toBe(true);
+ expect(noneEl.text()).toBe('None');
+ });
+
+ it('renders labels when `selectedLabels` is not empty', () => {
+ createComponent();
+
+ expect(wrapper.findAll(GlLabel).length).toBe(2);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js
new file mode 100644
index 00000000000..23810339833
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js
@@ -0,0 +1,84 @@
+import { GlIcon, GlLink } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+
+import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue';
+import { mockRegularLabel } from './mock_data';
+
+const mockLabel = { ...mockRegularLabel, set: true };
+
+const createComponent = ({
+ label = mockLabel,
+ isLabelSet = mockLabel.set,
+ highlight = true,
+} = {}) =>
+ shallowMount(LabelItem, {
+ propsData: {
+ label,
+ isLabelSet,
+ highlight,
+ },
+ });
+
+describe('LabelItem', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ it('renders gl-link component', () => {
+ expect(wrapper.find(GlLink).exists()).toBe(true);
+ });
+
+ it('renders component root with class `is-focused` when `highlight` prop is true', () => {
+ const wrapperTemp = createComponent({
+ highlight: true,
+ });
+
+ expect(wrapperTemp.classes()).toContain('is-focused');
+
+ wrapperTemp.destroy();
+ });
+
+ it('renders visible gl-icon component when `isLabelSet` prop is true', () => {
+ const wrapperTemp = createComponent({
+ isLabelSet: true,
+ });
+
+ const iconEl = wrapperTemp.find(GlIcon);
+
+ expect(iconEl.isVisible()).toBe(true);
+ expect(iconEl.props('name')).toBe('mobile-issue-close');
+
+ wrapperTemp.destroy();
+ });
+
+ it('renders visible span element as placeholder instead of gl-icon when `isLabelSet` prop is false', () => {
+ const wrapperTemp = createComponent({
+ isLabelSet: false,
+ });
+
+ const placeholderEl = wrapperTemp.find('[data-testid="no-icon"]');
+
+ expect(placeholderEl.isVisible()).toBe(true);
+
+ wrapperTemp.destroy();
+ });
+
+ it('renders label color element', () => {
+ const colorEl = wrapper.find('[data-testid="label-color-box"]');
+
+ expect(colorEl.exists()).toBe(true);
+ expect(colorEl.attributes('style')).toBe('background-color: rgb(186, 218, 85);');
+ });
+
+ it('renders label title', () => {
+ expect(wrapper.text()).toContain(mockLabel.title);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js
new file mode 100644
index 00000000000..ee1346c362f
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js
@@ -0,0 +1,241 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+
+import { isInViewport } from '~/lib/utils/common_utils';
+import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
+import DropdownButton from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue';
+import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue';
+import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue';
+import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue';
+import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue';
+import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
+
+import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
+
+import { mockConfig } from './mock_data';
+
+jest.mock('~/lib/utils/common_utils', () => ({
+ isInViewport: jest.fn().mockReturnValue(true),
+}));
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('LabelsSelectRoot', () => {
+ let wrapper;
+ let store;
+
+ const createComponent = (config = mockConfig, slots = {}) => {
+ wrapper = shallowMount(LabelsSelectRoot, {
+ localVue,
+ slots,
+ store,
+ propsData: config,
+ stubs: {
+ 'dropdown-contents': DropdownContents,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ store = new Vuex.Store(labelsSelectModule());
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('methods', () => {
+ describe('handleVuexActionDispatch', () => {
+ it('calls `handleDropdownClose` when params `action.type` is `toggleDropdownContents` and state has `showDropdownButton` & `showDropdownContents` props `false`', () => {
+ createComponent();
+ jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation();
+
+ wrapper.vm.handleVuexActionDispatch(
+ { type: 'toggleDropdownContents' },
+ {
+ showDropdownButton: false,
+ showDropdownContents: false,
+ labels: [{ id: 1 }, { id: 2, touched: true }],
+ },
+ );
+
+ expect(wrapper.vm.handleDropdownClose).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ {
+ id: 2,
+ touched: true,
+ },
+ ]),
+ );
+ });
+
+ it('calls `handleDropdownClose` with state.labels filterd using `set` prop when dropdown variant is `embedded`', () => {
+ createComponent({
+ ...mockConfig,
+ variant: 'embedded',
+ });
+
+ jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation();
+
+ wrapper.vm.handleVuexActionDispatch(
+ { type: 'toggleDropdownContents' },
+ {
+ showDropdownButton: false,
+ showDropdownContents: false,
+ labels: [{ id: 1 }, { id: 2, set: true }],
+ },
+ );
+
+ expect(wrapper.vm.handleDropdownClose).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ {
+ id: 2,
+ set: true,
+ },
+ ]),
+ );
+ });
+ });
+
+ describe('handleDropdownClose', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('emits `updateSelectedLabels` & `onDropdownClose` events on component when provided `labels` param is not empty', () => {
+ wrapper.vm.handleDropdownClose([{ id: 1 }, { id: 2 }]);
+
+ expect(wrapper.emitted().updateSelectedLabels).toBeTruthy();
+ expect(wrapper.emitted().onDropdownClose).toBeTruthy();
+ });
+
+ it('emits only `onDropdownClose` event on component when provided `labels` param is empty', () => {
+ wrapper.vm.handleDropdownClose([]);
+
+ expect(wrapper.emitted().updateSelectedLabels).toBeFalsy();
+ expect(wrapper.emitted().onDropdownClose).toBeTruthy();
+ });
+ });
+
+ describe('handleCollapsedValueClick', () => {
+ it('emits `toggleCollapse` event on component', () => {
+ createComponent();
+ wrapper.vm.handleCollapsedValueClick();
+
+ expect(wrapper.emitted().toggleCollapse).toBeTruthy();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component with classes `labels-select-wrapper position-relative`', () => {
+ createComponent();
+ expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative');
+ });
+
+ it.each`
+ variant | cssClass
+ ${'standalone'} | ${'is-standalone'}
+ ${'embedded'} | ${'is-embedded'}
+ `(
+ 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"',
+ ({ variant, cssClass }) => {
+ createComponent({
+ ...mockConfig,
+ variant,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.classes()).toContain(cssClass);
+ });
+ },
+ );
+
+ it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', async () => {
+ createComponent();
+ await wrapper.vm.$nextTick;
+ expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true);
+ });
+
+ it('renders `dropdown-title` component', async () => {
+ createComponent();
+ await wrapper.vm.$nextTick;
+ expect(wrapper.find(DropdownTitle).exists()).toBe(true);
+ });
+
+ it('renders `dropdown-value` component', async () => {
+ createComponent(mockConfig, {
+ default: 'None',
+ });
+ await wrapper.vm.$nextTick;
+
+ const valueComp = wrapper.find(DropdownValue);
+
+ expect(valueComp.exists()).toBe(true);
+ expect(valueComp.text()).toBe('None');
+ });
+
+ it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', async () => {
+ createComponent();
+ wrapper.vm.$store.dispatch('toggleDropdownButton');
+ await wrapper.vm.$nextTick;
+ expect(wrapper.find(DropdownButton).exists()).toBe(true);
+ });
+
+ it('renders `dropdown-contents` component when `showDropdownButton` & `showDropdownContents` prop is `true`', async () => {
+ createComponent();
+ wrapper.vm.$store.dispatch('toggleDropdownContents');
+ await wrapper.vm.$nextTick;
+ expect(wrapper.find(DropdownContents).exists()).toBe(true);
+ });
+
+ describe('sets content direction based on viewport', () => {
+ describe.each(Object.values(DropdownVariant))(
+ 'when labels variant is "%s"',
+ ({ variant }) => {
+ beforeEach(() => {
+ createComponent({ ...mockConfig, variant });
+ wrapper.vm.$store.dispatch('toggleDropdownContents');
+ });
+
+ it('set direction when out of viewport', () => {
+ isInViewport.mockImplementation(() => false);
+ wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(true);
+ });
+ });
+
+ it('does not set direction when inside of viewport', () => {
+ isInViewport.mockImplementation(() => true);
+ wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false);
+ });
+ });
+ },
+ );
+ });
+ });
+
+ it('calls toggleDropdownContents action when isEditing prop is changing to true', async () => {
+ createComponent();
+
+ jest.spyOn(store, 'dispatch').mockResolvedValue();
+ await wrapper.setProps({ isEditing: true });
+
+ expect(store.dispatch).toHaveBeenCalledWith('toggleDropdownContents');
+ });
+
+ it('does not call toggleDropdownContents action when isEditing prop is changing to false', async () => {
+ createComponent();
+
+ jest.spyOn(store, 'dispatch').mockResolvedValue();
+ await wrapper.setProps({ isEditing: false });
+
+ expect(store.dispatch).not.toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js
new file mode 100644
index 00000000000..9e29030fb56
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js
@@ -0,0 +1,93 @@
+export const mockRegularLabel = {
+ id: 26,
+ title: 'Foo Label',
+ description: 'Foobar',
+ color: '#BADA55',
+ textColor: '#FFFFFF',
+};
+
+export const mockScopedLabel = {
+ id: 27,
+ title: 'Foo::Bar',
+ description: 'Foobar',
+ color: '#0033CC',
+ textColor: '#FFFFFF',
+};
+
+export const mockLabels = [
+ mockRegularLabel,
+ mockScopedLabel,
+ {
+ id: 28,
+ title: 'Bug',
+ description: 'Label for bugs',
+ color: '#FF0000',
+ textColor: '#FFFFFF',
+ },
+ {
+ id: 29,
+ title: 'Boog',
+ description: 'Label for bugs',
+ color: '#FF0000',
+ textColor: '#FFFFFF',
+ },
+];
+
+export const mockConfig = {
+ allowLabelEdit: true,
+ allowLabelCreate: true,
+ allowScopedLabels: true,
+ allowMultiselect: true,
+ labelsListTitle: 'Assign labels',
+ labelsCreateTitle: 'Create label',
+ variant: 'sidebar',
+ dropdownOnly: false,
+ selectedLabels: [mockRegularLabel, mockScopedLabel],
+ labelsSelectInProgress: false,
+ labelsFetchPath: '/gitlab-org/my-project/-/labels.json',
+ labelsManagePath: '/gitlab-org/my-project/-/labels',
+ labelsFilterBasePath: '/gitlab-org/my-project/issues',
+ labelsFilterParam: 'label_name',
+};
+
+export const mockSuggestedColors = {
+ '#009966': 'Green-cyan',
+ '#8fbc8f': 'Dark sea green',
+ '#3cb371': 'Medium sea green',
+ '#00b140': 'Green screen',
+ '#013220': 'Dark green',
+ '#6699cc': 'Blue-gray',
+ '#0000ff': 'Blue',
+ '#e6e6fa': 'Lavendar',
+ '#9400d3': 'Dark violet',
+ '#330066': 'Deep violet',
+ '#808080': 'Gray',
+ '#36454f': 'Charcoal grey',
+ '#f7e7ce': 'Champagne',
+ '#c21e56': 'Rose red',
+ '#cc338b': 'Magenta-pink',
+ '#dc143c': 'Crimson',
+ '#ff0000': 'Red',
+ '#cd5b45': 'Dark coral',
+ '#eee600': 'Titanium yellow',
+ '#ed9121': 'Carrot orange',
+ '#c39953': 'Aztec Gold',
+};
+
+export const createLabelSuccessfulResponse = {
+ data: {
+ labelCreate: {
+ label: {
+ id: 'gid://gitlab/ProjectLabel/126',
+ color: '#dc143c',
+ description: null,
+ descriptionHtml: '',
+ title: 'ewrwrwer',
+ textColor: '#FFFFFF',
+ __typename: 'Label',
+ },
+ errors: [],
+ __typename: 'LabelCreatePayload',
+ },
+ },
+};
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js
new file mode 100644
index 00000000000..7ef4b769b6b
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js
@@ -0,0 +1,176 @@
+import MockAdapter from 'axios-mock-adapter';
+
+import testAction from 'helpers/vuex_action_helper';
+import axios from '~/lib/utils/axios_utils';
+import * as actions from '~/vue_shared/components/sidebar/labels_select_widget/store/actions';
+import * as types from '~/vue_shared/components/sidebar/labels_select_widget/store/mutation_types';
+import defaultState from '~/vue_shared/components/sidebar/labels_select_widget/store/state';
+
+describe('LabelsSelect Actions', () => {
+ let state;
+ const mockInitialState = {
+ labels: [],
+ selectedLabels: [],
+ };
+
+ beforeEach(() => {
+ state = { ...defaultState() };
+ });
+
+ describe('setInitialState', () => {
+ it('sets initial store state', (done) => {
+ testAction(
+ actions.setInitialState,
+ mockInitialState,
+ state,
+ [{ type: types.SET_INITIAL_STATE, payload: mockInitialState }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('toggleDropdownButton', () => {
+ it('toggles dropdown button', (done) => {
+ testAction(
+ actions.toggleDropdownButton,
+ {},
+ state,
+ [{ type: types.TOGGLE_DROPDOWN_BUTTON }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('toggleDropdownContents', () => {
+ it('toggles dropdown contents', (done) => {
+ testAction(
+ actions.toggleDropdownContents,
+ {},
+ state,
+ [{ type: types.TOGGLE_DROPDOWN_CONTENTS }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('toggleDropdownContentsCreateView', () => {
+ it('toggles dropdown create view', (done) => {
+ testAction(
+ actions.toggleDropdownContentsCreateView,
+ {},
+ state,
+ [{ type: types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('requestLabels', () => {
+ it('sets value of `state.labelsFetchInProgress` to `true`', (done) => {
+ testAction(actions.requestLabels, {}, state, [{ type: types.REQUEST_LABELS }], [], done);
+ });
+ });
+
+ describe('receiveLabelsSuccess', () => {
+ it('sets provided labels to `state.labels`', (done) => {
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+
+ testAction(
+ actions.receiveLabelsSuccess,
+ labels,
+ state,
+ [{ type: types.RECEIVE_SET_LABELS_SUCCESS, payload: labels }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveLabelsFailure', () => {
+ beforeEach(() => {
+ setFixtures('<div class="flash-container"></div>');
+ });
+
+ it('sets value `state.labelsFetchInProgress` to `false`', (done) => {
+ testAction(
+ actions.receiveLabelsFailure,
+ {},
+ state,
+ [{ type: types.RECEIVE_SET_LABELS_FAILURE }],
+ [],
+ done,
+ );
+ });
+
+ it('shows flash error', () => {
+ actions.receiveLabelsFailure({ commit: () => {} });
+
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ 'Error fetching labels.',
+ );
+ });
+ });
+
+ describe('fetchLabels', () => {
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ state.labelsFetchPath = 'labels.json';
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('on success', () => {
+ it('dispatches `requestLabels` & `receiveLabelsSuccess` actions', (done) => {
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+ mock.onGet(/labels.json/).replyOnce(200, labels);
+
+ testAction(
+ actions.fetchLabels,
+ {},
+ state,
+ [],
+ [{ type: 'requestLabels' }, { type: 'receiveLabelsSuccess', payload: labels }],
+ done,
+ );
+ });
+ });
+
+ describe('on failure', () => {
+ it('dispatches `requestLabels` & `receiveLabelsFailure` actions', (done) => {
+ mock.onGet(/labels.json/).replyOnce(500, {});
+
+ testAction(
+ actions.fetchLabels,
+ {},
+ state,
+ [],
+ [{ type: 'requestLabels' }, { type: 'receiveLabelsFailure' }],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('updateSelectedLabels', () => {
+ it('updates `state.labels` based on provided `labels` param', (done) => {
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+
+ testAction(
+ actions.updateSelectedLabels,
+ labels,
+ state,
+ [{ type: types.UPDATE_SELECTED_LABELS, payload: { labels } }],
+ [],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js
new file mode 100644
index 00000000000..40eb0323146
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js
@@ -0,0 +1,59 @@
+import * as getters from '~/vue_shared/components/sidebar/labels_select_widget/store/getters';
+
+describe('LabelsSelect Getters', () => {
+ describe('dropdownButtonText', () => {
+ it.each`
+ labelType | dropdownButtonText | expected
+ ${'default'} | ${''} | ${'Label'}
+ ${'custom'} | ${'Custom label'} | ${'Custom label'}
+ `(
+ 'returns $labelType text when state.labels has no selected labels',
+ ({ dropdownButtonText, expected }) => {
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+ const selectedLabels = [];
+ const state = { labels, selectedLabels, dropdownButtonText };
+
+ expect(getters.dropdownButtonText(state, {})).toBe(expected);
+ },
+ );
+
+ it('returns label title when state.labels has only 1 label', () => {
+ const labels = [{ id: 1, title: 'Foobar', set: true }];
+
+ expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe(
+ 'Foobar',
+ );
+ });
+
+ it('returns first label title and remaining labels count when state.labels has more than 1 label', () => {
+ const labels = [
+ { id: 1, title: 'Foo', set: true },
+ { id: 2, title: 'Bar', set: true },
+ ];
+
+ expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe(
+ 'Foo +1 more',
+ );
+ });
+ });
+
+ describe('selectedLabelsList', () => {
+ it('returns array of IDs of all labels within `state.selectedLabels`', () => {
+ const selectedLabels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+
+ expect(getters.selectedLabelsList({ selectedLabels })).toEqual([1, 2, 3, 4]);
+ });
+ });
+
+ describe('isDropdownVariantSidebar', () => {
+ it('returns `true` when `state.variant` is "sidebar"', () => {
+ expect(getters.isDropdownVariantSidebar({ variant: 'sidebar' })).toBe(true);
+ });
+ });
+
+ describe('isDropdownVariantStandalone', () => {
+ it('returns `true` when `state.variant` is "standalone"', () => {
+ expect(getters.isDropdownVariantStandalone({ variant: 'standalone' })).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js
new file mode 100644
index 00000000000..acb275b5d90
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js
@@ -0,0 +1,140 @@
+import * as types from '~/vue_shared/components/sidebar/labels_select_widget/store/mutation_types';
+import mutations from '~/vue_shared/components/sidebar/labels_select_widget/store/mutations';
+
+describe('LabelsSelect Mutations', () => {
+ describe(`${types.SET_INITIAL_STATE}`, () => {
+ it('initializes provided props to store state', () => {
+ const state = {};
+ mutations[types.SET_INITIAL_STATE](state, {
+ labels: 'foo',
+ });
+
+ expect(state.labels).toEqual('foo');
+ });
+ });
+
+ describe(`${types.TOGGLE_DROPDOWN_BUTTON}`, () => {
+ it('toggles value of `state.showDropdownButton`', () => {
+ const state = {
+ showDropdownButton: false,
+ };
+ mutations[types.TOGGLE_DROPDOWN_BUTTON](state);
+
+ expect(state.showDropdownButton).toBe(true);
+ });
+ });
+
+ describe(`${types.TOGGLE_DROPDOWN_CONTENTS}`, () => {
+ it('toggles value of `state.showDropdownButton` when `state.dropdownOnly` is false', () => {
+ const state = {
+ dropdownOnly: false,
+ showDropdownButton: false,
+ variant: 'sidebar',
+ };
+ mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
+
+ expect(state.showDropdownButton).toBe(true);
+ });
+
+ it('toggles value of `state.showDropdownContents`', () => {
+ const state = {
+ showDropdownContents: false,
+ };
+ mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
+
+ expect(state.showDropdownContents).toBe(true);
+ });
+
+ it('sets value of `state.showDropdownContentsCreateView` to `false` when `showDropdownContents` is true', () => {
+ const state = {
+ showDropdownContents: false,
+ showDropdownContentsCreateView: true,
+ };
+ mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
+
+ expect(state.showDropdownContentsCreateView).toBe(false);
+ });
+ });
+
+ describe(`${types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW}`, () => {
+ it('toggles value of `state.showDropdownContentsCreateView`', () => {
+ const state = {
+ showDropdownContentsCreateView: false,
+ };
+ mutations[types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state);
+
+ expect(state.showDropdownContentsCreateView).toBe(true);
+ });
+ });
+
+ describe(`${types.REQUEST_LABELS}`, () => {
+ it('sets value of `state.labelsFetchInProgress` to true', () => {
+ const state = {
+ labelsFetchInProgress: false,
+ };
+ mutations[types.REQUEST_LABELS](state);
+
+ expect(state.labelsFetchInProgress).toBe(true);
+ });
+ });
+
+ describe(`${types.RECEIVE_SET_LABELS_SUCCESS}`, () => {
+ const selectedLabels = [{ id: 2 }, { id: 4 }];
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+
+ it('sets value of `state.labelsFetchInProgress` to false', () => {
+ const state = {
+ selectedLabels,
+ labelsFetchInProgress: true,
+ };
+ mutations[types.RECEIVE_SET_LABELS_SUCCESS](state, labels);
+
+ expect(state.labelsFetchInProgress).toBe(false);
+ });
+
+ it('sets provided `labels` to `state.labels` along with `set` prop based on `state.selectedLabels`', () => {
+ const selectedLabelIds = selectedLabels.map((label) => label.id);
+ const state = {
+ selectedLabels,
+ labelsFetchInProgress: true,
+ };
+ mutations[types.RECEIVE_SET_LABELS_SUCCESS](state, labels);
+
+ state.labels.forEach((label) => {
+ if (selectedLabelIds.includes(label.id)) {
+ expect(label.set).toBe(true);
+ }
+ });
+ });
+ });
+
+ describe(`${types.RECEIVE_SET_LABELS_FAILURE}`, () => {
+ it('sets value of `state.labelsFetchInProgress` to false', () => {
+ const state = {
+ labelsFetchInProgress: true,
+ };
+ mutations[types.RECEIVE_SET_LABELS_FAILURE](state);
+
+ expect(state.labelsFetchInProgress).toBe(false);
+ });
+ });
+
+ describe(`${types.UPDATE_SELECTED_LABELS}`, () => {
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+
+ it('updates `state.labels` to include `touched` and `set` props based on provided `labels` param', () => {
+ const updatedLabelIds = [2];
+ const state = {
+ labels,
+ };
+ mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: 2 }] });
+
+ state.labels.forEach((label) => {
+ if (updatedLabelIds.includes(label.id)) {
+ expect(label.touched).toBe(true);
+ expect(label.set).toBe(true);
+ }
+ });
+ });
+ });
+});
diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb
index 59b42dfca20..a8a227c8ec4 100644
--- a/spec/helpers/issues_helper_spec.rb
+++ b/spec/helpers/issues_helper_spec.rb
@@ -302,7 +302,6 @@ RSpec.describe IssuesHelper do
email: current_user&.notification_email,
emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'),
empty_state_svg_path: '#',
- endpoint: expose_path(api_v4_projects_issues_path(id: project.id)),
export_csv_path: export_csv_project_issues_path(project),
has_project_issues: project_issues(project).exists?.to_s,
import_csv_issues_path: '#',
diff --git a/spec/lib/banzai/filter/references/external_issue_reference_filter_spec.rb b/spec/lib/banzai/filter/references/external_issue_reference_filter_spec.rb
index 3b274f98020..7557b9a118d 100644
--- a/spec/lib/banzai/filter/references/external_issue_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/references/external_issue_reference_filter_spec.rb
@@ -213,7 +213,9 @@ RSpec.describe Banzai::Filter::References::ExternalIssueReferenceFilter do
end
context "ewm project" do
- let_it_be(:service) { create(:ewm_service, project: project) }
+ let_it_be(:integration) { create(:ewm_integration, project: project) }
+
+ let(:service) { integration } # TODO: remove when https://gitlab.com/gitlab-org/gitlab/-/issues/330300 is complete
before do
project.update!(issues_enabled: false)
diff --git a/spec/lib/gitlab/ci/templates/templates_spec.rb b/spec/lib/gitlab/ci/templates/templates_spec.rb
index 2e6df7da232..81fc66c4a11 100644
--- a/spec/lib/gitlab/ci/templates/templates_spec.rb
+++ b/spec/lib/gitlab/ci/templates/templates_spec.rb
@@ -27,16 +27,17 @@ RSpec.describe 'CI YML Templates' do
end
context 'that support autodevops' do
- non_autodevops_templates = [
- 'Security/DAST-API.gitlab-ci.yml',
- 'Security/API-Fuzzing.gitlab-ci.yml'
+ exceptions = [
+ 'Security/DAST.gitlab-ci.yml', # DAST stage is defined inside AutoDevops yml
+ 'Security/DAST-API.gitlab-ci.yml', # no auto-devops
+ 'Security/API-Fuzzing.gitlab-ci.yml' # no auto-devops
]
context 'when including available templates in a CI YAML configuration' do
using RSpec::Parameterized::TableSyntax
where(:template_name) do
- all_templates - excluded_templates - non_autodevops_templates
+ all_templates - excluded_templates - exceptions
end
with_them do
diff --git a/spec/lib/gitlab/database/migrations/observers/query_details_spec.rb b/spec/lib/gitlab/database/migrations/observers/query_details_spec.rb
new file mode 100644
index 00000000000..8aac3ed67c6
--- /dev/null
+++ b/spec/lib/gitlab/database/migrations/observers/query_details_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Migrations::Observers::QueryDetails do
+ subject { described_class.new }
+
+ let(:observation) { Gitlab::Database::Migrations::Observation.new(migration) }
+ let(:connection) { ActiveRecord::Base.connection }
+ let(:query) { "select date_trunc('day', $1::timestamptz) + $2 * (interval '1 hour')" }
+ let(:query_binds) { [Time.current, 3] }
+ let(:directory_path) { Dir.mktmpdir }
+ let(:log_file) { "#{directory_path}/#{migration}-query-details.json" }
+ let(:query_details) { Gitlab::Json.parse(File.read(log_file)) }
+ let(:migration) { 20210422152437 }
+
+ before do
+ stub_const('Gitlab::Database::Migrations::Instrumentation::RESULT_DIR', directory_path)
+ end
+
+ after do
+ FileUtils.remove_entry(directory_path)
+ end
+
+ it 'records details of executed queries' do
+ observe
+
+ expect(query_details.size).to eq(1)
+
+ log_entry = query_details[0]
+ start_time, end_time, sql, binds = log_entry.values_at('start_time', 'end_time', 'sql', 'binds')
+ start_time = DateTime.parse(start_time)
+ end_time = DateTime.parse(end_time)
+
+ aggregate_failures do
+ expect(sql).to include(query)
+ expect(start_time).to be_before(end_time)
+ expect(binds).to eq(query_binds.map { |b| connection.type_cast(b) })
+ end
+ end
+
+ it 'unsubscribes after the observation' do
+ observe
+
+ expect(subject).not_to receive(:record_sql_event)
+ run_query
+ end
+
+ def observe
+ subject.before
+ run_query
+ subject.after
+ subject.record(observation)
+ end
+
+ def run_query
+ connection.exec_query(query, 'SQL', query_binds)
+ end
+end
diff --git a/spec/lib/gitlab/exclusive_lease_spec.rb b/spec/lib/gitlab/exclusive_lease_spec.rb
index e730ddd6577..968d26e1c38 100644
--- a/spec/lib/gitlab/exclusive_lease_spec.rb
+++ b/spec/lib/gitlab/exclusive_lease_spec.rb
@@ -166,4 +166,82 @@ RSpec.describe Gitlab::ExclusiveLease, :clean_gitlab_redis_shared_state do
expect(described_class.get_uuid(unique_key)).to be_falsey
end
end
+
+ describe '.throttle' do
+ it 'prevents repeated execution of the block' do
+ number = 0
+
+ action = -> { described_class.throttle(1) { number += 1 } }
+
+ action.call
+ action.call
+
+ expect(number).to eq 1
+ end
+
+ it 'is distinct by block' do
+ number = 0
+
+ described_class.throttle(1) { number += 1 }
+ described_class.throttle(1) { number += 1 }
+
+ expect(number).to eq 2
+ end
+
+ it 'is distinct by key' do
+ number = 0
+
+ action = ->(k) { described_class.throttle(k) { number += 1 } }
+
+ action.call(:a)
+ action.call(:b)
+ action.call(:a)
+
+ expect(number).to eq 2
+ end
+
+ it 'allows a group to be passed' do
+ number = 0
+
+ described_class.throttle(1, group: :a) { number += 1 }
+ described_class.throttle(1, group: :b) { number += 1 }
+ described_class.throttle(1, group: :a) { number += 1 }
+ described_class.throttle(1, group: :b) { number += 1 }
+
+ expect(number).to eq 2
+ end
+
+ it 'defaults to a 60min timeout' do
+ expect(described_class).to receive(:new).with(anything, hash_including(timeout: 1.hour.to_i)).and_call_original
+
+ described_class.throttle(1) {}
+ end
+
+ it 'allows count to be specified' do
+ expect(described_class)
+ .to receive(:new)
+ .with(anything, hash_including(timeout: 15.minutes.to_i))
+ .and_call_original
+
+ described_class.throttle(1, count: 4) {}
+ end
+
+ it 'allows period to be specified' do
+ expect(described_class)
+ .to receive(:new)
+ .with(anything, hash_including(timeout: 1.day.to_i))
+ .and_call_original
+
+ described_class.throttle(1, period: 1.day) {}
+ end
+
+ it 'allows period and count to be specified' do
+ expect(described_class)
+ .to receive(:new)
+ .with(anything, hash_including(timeout: 30.minutes.to_i))
+ .and_call_original
+
+ described_class.throttle(1, count: 48, period: 1.day) {}
+ end
+ end
end
diff --git a/spec/lib/gitlab/git/remote_mirror_spec.rb b/spec/lib/gitlab/git/remote_mirror_spec.rb
index 92504b7aafe..0954879f6bd 100644
--- a/spec/lib/gitlab/git/remote_mirror_spec.rb
+++ b/spec/lib/gitlab/git/remote_mirror_spec.rb
@@ -7,16 +7,29 @@ RSpec.describe Gitlab::Git::RemoteMirror do
let(:project) { create(:project, :repository) }
let(:repository) { project.repository }
let(:ref_name) { 'foo' }
+ let(:url) { 'https://example.com' }
let(:options) { { only_branches_matching: ['master'], ssh_key: 'KEY', known_hosts: 'KNOWN HOSTS', keep_divergent_refs: true } }
- subject(:remote_mirror) { described_class.new(repository, ref_name, **options) }
+ subject(:remote_mirror) { described_class.new(repository, ref_name, url, **options) }
- it 'delegates to the Gitaly client' do
- expect(repository.gitaly_remote_client)
- .to receive(:update_remote_mirror)
- .with(ref_name, ['master'], ssh_key: 'KEY', known_hosts: 'KNOWN HOSTS', keep_divergent_refs: true)
+ shared_examples 'an update' do
+ it 'delegates to the Gitaly client' do
+ expect(repository.gitaly_remote_client)
+ .to receive(:update_remote_mirror)
+ .with(ref_name, url, ['master'], ssh_key: 'KEY', known_hosts: 'KNOWN HOSTS', keep_divergent_refs: true)
+
+ remote_mirror.update # rubocop:disable Rails/SaveBang
+ end
+ end
+
+ context 'with url' do
+ it_behaves_like 'an update'
+ end
+
+ context 'without url' do
+ let(:url) { nil }
- remote_mirror.update # rubocop:disable Rails/SaveBang
+ it_behaves_like 'an update'
end
it 'wraps gitaly errors' do
diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb
index 96a44575e24..3ee0310a9a2 100644
--- a/spec/lib/gitlab/git_access_spec.rb
+++ b/spec/lib/gitlab/git_access_spec.rb
@@ -440,6 +440,14 @@ RSpec.describe Gitlab::GitAccess do
expect { pull_access_check }.to raise_forbidden("Your password expired. Please access GitLab from a web browser to update your password.")
end
+ it 'allows ldap users with expired password to pull' do
+ project.add_maintainer(user)
+ user.update!(password_expires_at: 2.minutes.ago)
+ allow(user).to receive(:ldap_user?).and_return(true)
+
+ expect { pull_access_check }.not_to raise_error
+ end
+
context 'when the project repository does not exist' do
before do
project.add_guest(user)
@@ -979,12 +987,26 @@ RSpec.describe Gitlab::GitAccess do
end
it 'disallows users with expired password to push' do
- project.add_maintainer(user)
user.update!(password_expires_at: 2.minutes.ago)
expect { push_access_check }.to raise_forbidden("Your password expired. Please access GitLab from a web browser to update your password.")
end
+ it 'allows ldap users with expired password to push' do
+ user.update!(password_expires_at: 2.minutes.ago)
+ allow(user).to receive(:ldap_user?).and_return(true)
+
+ expect { push_access_check }.not_to raise_error
+ end
+
+ it 'disallows blocked ldap users with expired password to push' do
+ user.block
+ user.update!(password_expires_at: 2.minutes.ago)
+ allow(user).to receive(:ldap_user?).and_return(true)
+
+ expect { push_access_check }.to raise_forbidden("Your account has been blocked.")
+ end
+
it 'cleans up the files' do
expect(project.repository).to receive(:clean_stale_repository_files).and_call_original
expect { push_access_check }.not_to raise_error
diff --git a/spec/lib/gitlab/gitaly_client/remote_service_spec.rb b/spec/lib/gitlab/gitaly_client/remote_service_spec.rb
index df9dde324a5..2ec5f70be76 100644
--- a/spec/lib/gitlab/gitaly_client/remote_service_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/remote_service_spec.rb
@@ -67,13 +67,29 @@ RSpec.describe Gitlab::GitalyClient::RemoteService do
let(:ssh_key) { 'KEY' }
let(:known_hosts) { 'KNOWN HOSTS' }
- it 'sends an update_remote_mirror message' do
- expect_any_instance_of(Gitaly::RemoteService::Stub)
- .to receive(:update_remote_mirror)
- .with(kind_of(Enumerator), kind_of(Hash))
- .and_return(double(:update_remote_mirror_response))
+ shared_examples 'an update' do
+ it 'sends an update_remote_mirror message' do
+ expect_any_instance_of(Gitaly::RemoteService::Stub)
+ .to receive(:update_remote_mirror)
+ .with(array_including(gitaly_request_with_params(expected_params)), kind_of(Hash))
+ .and_return(double(:update_remote_mirror_response))
+
+ client.update_remote_mirror(ref_name, url, only_branches_matching, ssh_key: ssh_key, known_hosts: known_hosts, keep_divergent_refs: true)
+ end
+ end
+
+ context 'with remote name' do
+ let(:url) { nil }
+ let(:expected_params) { { ref_name: ref_name } }
+
+ it_behaves_like 'an update'
+ end
+
+ context 'with remote URL' do
+ let(:url) { 'http:://git.example.com/my-repo.git' }
+ let(:expected_params) { { remote: Gitaly::UpdateRemoteMirrorRequest::Remote.new(url: url) } }
- client.update_remote_mirror(ref_name, only_branches_matching, ssh_key: ssh_key, known_hosts: known_hosts, keep_divergent_refs: true)
+ it_behaves_like 'an update'
end
end
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 781c55f8d9b..87a10b52b22 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -366,21 +366,21 @@ project:
- datadog_integration
- discord_integration
- drone_ci_integration
-- emails_on_push_service
+- emails_on_push_integration
- pipelines_email_service
- mattermost_slash_commands_service
- slack_slash_commands_service
-- irker_service
+- irker_integration
- packagist_service
- pivotaltracker_service
- prometheus_service
-- flowdock_service
+- flowdock_integration
- assembla_integration
- asana_integration
- slack_service
- microsoft_teams_service
- mattermost_service
-- hangouts_chat_service
+- hangouts_chat_integration
- unify_circuit_service
- buildkite_integration
- bamboo_integration
@@ -391,8 +391,8 @@ project:
- youtrack_service
- custom_issue_tracker_integration
- bugzilla_integration
-- ewm_service
-- external_wiki_service
+- ewm_integration
+- external_wiki_integration
- mock_ci_service
- mock_monitoring_service
- forked_to_members
diff --git a/spec/lib/gitlab/pagination/keyset/paginator_spec.rb b/spec/lib/gitlab/pagination/keyset/paginator_spec.rb
index 3c9a8913876..230ac01af31 100644
--- a/spec/lib/gitlab/pagination/keyset/paginator_spec.rb
+++ b/spec/lib/gitlab/pagination/keyset/paginator_spec.rb
@@ -117,4 +117,27 @@ RSpec.describe Gitlab::Pagination::Keyset::Paginator do
expect { scope.keyset_paginate }.to raise_error(/does not support keyset pagination/)
end
end
+
+ context 'when use_union_optimization option is true and ordering by two columns' do
+ let(:scope) { Project.order(name: :asc, id: :desc) }
+
+ it 'uses UNION queries' do
+ paginator_first_page = scope.keyset_paginate(
+ per_page: 2,
+ keyset_order_options: { use_union_optimization: true }
+ )
+
+ paginator_second_page = scope.keyset_paginate(
+ per_page: 2,
+ cursor: paginator_first_page.cursor_for_next_page,
+ keyset_order_options: { use_union_optimization: true }
+ )
+
+ expect_next_instances_of(Gitlab::SQL::Union, 1) do |instance|
+ expect(instance.to_sql).to include(paginator_first_page.records.last.name)
+ end
+
+ paginator_second_page.records.to_a
+ end
+ end
end
diff --git a/spec/lib/sidebars/projects/menus/external_wiki_menu_spec.rb b/spec/lib/sidebars/projects/menus/external_wiki_menu_spec.rb
index 19efd2bbd6b..a8f4b039b8c 100644
--- a/spec/lib/sidebars/projects/menus/external_wiki_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/external_wiki_menu_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe Sidebars::Projects::Menus::ExternalWikiMenu do
end
context 'when active external issue tracker' do
- let(:external_wiki) { build(:external_wiki_service, project: project) }
+ let(:external_wiki) { build(:external_wiki_integration, project: project) }
context 'is present' do
it 'returns true' do
diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb
index 3418d7d39ad..4bfa953df40 100644
--- a/spec/models/ability_spec.rb
+++ b/spec/models/ability_spec.rb
@@ -342,4 +342,45 @@ RSpec.describe Ability do
end
end
end
+
+ describe 'forgetting', :request_store do
+ it 'allows us to discard specific values from the DeclarativePolicy cache' do
+ user_a = build_stubbed(:user)
+ user_b = build_stubbed(:user)
+
+ # expect these keys to remain
+ Gitlab::SafeRequestStore[:administrator] = :wibble
+ Gitlab::SafeRequestStore['admin'] = :wobble
+ described_class.allowed?(user_b, :read_all_resources)
+ # expect the DeclarativePolicy cache keys added by this action not to remain
+ described_class.forgetting(/admin/) do
+ described_class.allowed?(user_a, :read_all_resources)
+ end
+
+ keys = Gitlab::SafeRequestStore.storage.keys
+
+ expect(keys).to include(
+ :administrator,
+ 'admin',
+ "/dp/condition/BasePolicy/admin/#{user_b.id}"
+ )
+ expect(keys).not_to include("/dp/condition/BasePolicy/admin/#{user_a.id}")
+ end
+
+ # regression spec for re-entrant admin condition checks
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/332983
+ context 'when bypassing the session' do
+ let(:user) { build_stubbed(:admin) }
+ let(:ability) { :admin_all_resources } # any admin-only ability is fine here.
+
+ def check_ability
+ described_class.forgetting(/admin/) { described_class.allowed?(user, ability) }
+ end
+
+ it 'allows us to have re-entrant evaluation of admin-only permissions' do
+ expect { Gitlab::Auth::CurrentUserMode.bypass_session!(user.id) }
+ .to change { check_ability }.from(false).to(true)
+ end
+ end
+ end
end
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index 72af40e31e0..26fc4b140c1 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -4625,8 +4625,11 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#build_matchers' do
- let_it_be(:pipeline) { create(:ci_pipeline) }
- let_it_be(:builds) { create_list(:ci_build, 2, pipeline: pipeline, project: pipeline.project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:pipeline) { create(:ci_pipeline, user: user) }
+ let_it_be(:builds) { create_list(:ci_build, 2, pipeline: pipeline, project: pipeline.project, user: user) }
+
+ let(:project) { pipeline.project }
subject(:matchers) { pipeline.build_matchers }
@@ -4635,5 +4638,22 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
expect(matchers).to all be_a(Gitlab::Ci::Matching::BuildMatcher)
expect(matchers.first.build_ids).to match_array(builds.map(&:id))
end
+
+ context 'with retried builds' do
+ let(:retried_build) { builds.first }
+
+ before do
+ stub_not_protect_default_branch
+ project.add_developer(user)
+
+ retried_build.cancel!
+ ::Ci::Build.retry(retried_build, user)
+ end
+
+ it 'does not include retried builds' do
+ expect(matchers.size).to eq(1)
+ expect(matchers.first.build_ids).not_to include(retried_build.id)
+ end
+ end
end
end
diff --git a/spec/models/clusters/platforms/kubernetes_spec.rb b/spec/models/clusters/platforms/kubernetes_spec.rb
index 07e64889b93..a4cae93ff84 100644
--- a/spec/models/clusters/platforms/kubernetes_spec.rb
+++ b/spec/models/clusters/platforms/kubernetes_spec.rb
@@ -439,16 +439,6 @@ RSpec.describe Clusters::Platforms::Kubernetes do
include_examples 'successful deployment request'
end
-
- context 'when canary_ingress_weight_control feature flag is disabled' do
- before do
- stub_feature_flags(canary_ingress_weight_control: false)
- end
-
- it 'does not fetch ingress data from kubernetes' do
- expect(subject[:ingresses]).to be_empty
- end
- end
end
context 'when the kubernetes integration is disabled' do
diff --git a/spec/models/container_expiration_policy_spec.rb b/spec/models/container_expiration_policy_spec.rb
index 32ec5ed161a..191913ed454 100644
--- a/spec/models/container_expiration_policy_spec.rb
+++ b/spec/models/container_expiration_policy_spec.rb
@@ -139,15 +139,23 @@ RSpec.describe ContainerExpirationPolicy, type: :model do
end
end
- describe '.with_container_repositories' do
- subject { described_class.with_container_repositories }
-
+ context 'policies with container repositories' do
let_it_be(:policy1) { create(:container_expiration_policy) }
let_it_be(:container_repository1) { create(:container_repository, project: policy1.project) }
let_it_be(:policy2) { create(:container_expiration_policy) }
let_it_be(:container_repository2) { create(:container_repository, project: policy2.project) }
let_it_be(:policy3) { create(:container_expiration_policy) }
- it { is_expected.to contain_exactly(policy1, policy2) }
+ describe '.with_container_repositories' do
+ subject { described_class.with_container_repositories }
+
+ it { is_expected.to contain_exactly(policy1, policy2) }
+ end
+
+ describe '.without_container_repositories' do
+ subject { described_class.without_container_repositories }
+
+ it { is_expected.to contain_exactly(policy3) }
+ end
end
end
diff --git a/spec/models/integrations/emails_on_push_spec.rb b/spec/models/integrations/emails_on_push_spec.rb
index ca060f4155e..c82d4bdff9b 100644
--- a/spec/models/integrations/emails_on_push_spec.rb
+++ b/spec/models/integrations/emails_on_push_spec.rb
@@ -88,7 +88,7 @@ RSpec.describe Integrations::EmailsOnPush do
describe '#execute' do
let(:push_data) { { object_kind: 'push' } }
let(:project) { create(:project, :repository) }
- let(:service) { create(:emails_on_push_service, project: project) }
+ let(:integration) { create(:emails_on_push_integration, project: project) }
let(:recipients) { 'test@gitlab.com' }
before do
@@ -105,7 +105,7 @@ RSpec.describe Integrations::EmailsOnPush do
it 'sends email' do
expect(EmailsOnPushWorker).not_to receive(:perform_async)
- service.execute(push_data)
+ integration.execute(push_data)
end
end
@@ -119,7 +119,7 @@ RSpec.describe Integrations::EmailsOnPush do
it 'does not send email' do
expect(EmailsOnPushWorker).not_to receive(:perform_async)
- service.execute(push_data)
+ integration.execute(push_data)
end
end
@@ -128,7 +128,7 @@ RSpec.describe Integrations::EmailsOnPush do
expect(project).to receive(:emails_disabled?).and_return(true)
expect(EmailsOnPushWorker).not_to receive(:perform_async)
- service.execute(push_data)
+ integration.execute(push_data)
end
end
diff --git a/spec/models/integrations/flowdock_spec.rb b/spec/models/integrations/flowdock_spec.rb
index 2de6f7dd2f1..189831fa32d 100644
--- a/spec/models/integrations/flowdock_spec.rb
+++ b/spec/models/integrations/flowdock_spec.rb
@@ -29,27 +29,29 @@ RSpec.describe Integrations::Flowdock do
describe "Execute" do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
+ let(:sample_data) { Gitlab::DataBuilder::Push.build_sample(project, user) }
+ let(:api_url) { 'https://api.flowdock.com/v1/messages' }
+
+ subject(:flowdock_integration) { described_class.new }
before do
- @flowdock_service = described_class.new
- allow(@flowdock_service).to receive_messages(
+ allow(flowdock_integration).to receive_messages(
project_id: project.id,
project: project,
service_hook: true,
token: 'verySecret'
)
- @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user)
- @api_url = 'https://api.flowdock.com/v1/messages'
- WebMock.stub_request(:post, @api_url)
+ WebMock.stub_request(:post, api_url)
end
it "calls FlowDock API" do
- @flowdock_service.execute(@sample_data)
- @sample_data[:commits].each do |commit|
+ flowdock_integration.execute(sample_data)
+
+ sample_data[:commits].each do |commit|
# One request to Flowdock per new commit
- next if commit[:id] == @sample_data[:before]
+ next if commit[:id] == sample_data[:before]
- expect(WebMock).to have_requested(:post, @api_url).with(
+ expect(WebMock).to have_requested(:post, api_url).with(
body: /#{commit[:id]}.*#{project.path}/
).once
end
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index 94b4c1901b8..73b1cb13f19 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -4951,4 +4951,15 @@ RSpec.describe MergeRequest, factory_default: :keep do
it { is_expected.to eq(true) }
end
end
+
+ describe '.from_fork' do
+ let!(:project) { create(:project, :repository) }
+ let!(:forked_project) { fork_project(project) }
+ let!(:fork_mr) { create(:merge_request, source_project: forked_project, target_project: project) }
+ let!(:regular_mr) { create(:merge_request, source_project: project) }
+
+ it 'returns merge requests from forks only' do
+ expect(described_class.from_fork).to eq([fork_mr])
+ end
+ end
end
diff --git a/spec/models/packages/package_spec.rb b/spec/models/packages/package_spec.rb
index 1e44327c089..b2c1d51e4af 100644
--- a/spec/models/packages/package_spec.rb
+++ b/spec/models/packages/package_spec.rb
@@ -1019,10 +1019,24 @@ RSpec.describe Packages::Package, type: :model do
package.composer_metadatum.reload
end
- it 'schedule the update job' do
- expect(::Packages::Composer::CacheUpdateWorker).to receive(:perform_async).with(project.id, package_name, package.composer_metadatum.version_cache_sha)
+ context 'with feature flag disabled' do
+ before do
+ stub_feature_flags(disable_composer_callback: false)
+ end
+
+ it 'schedule the update job' do
+ expect(::Packages::Composer::CacheUpdateWorker).to receive(:perform_async).with(project.id, package_name, package.composer_metadatum.version_cache_sha)
+
+ package.destroy!
+ end
+ end
- package.destroy!
+ context 'with feature flag enabled' do
+ it 'does nothing' do
+ expect(::Packages::Composer::CacheUpdateWorker).not_to receive(:perform_async)
+
+ package.destroy!
+ end
end
end
end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 78e32571d7d..7eb02749f72 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -38,7 +38,7 @@ RSpec.describe Project, factory_default: :keep do
it { is_expected.to have_one(:slack_service) }
it { is_expected.to have_one(:microsoft_teams_service) }
it { is_expected.to have_one(:mattermost_service) }
- it { is_expected.to have_one(:hangouts_chat_service) }
+ it { is_expected.to have_one(:hangouts_chat_integration) }
it { is_expected.to have_one(:unify_circuit_service) }
it { is_expected.to have_one(:webex_teams_service) }
it { is_expected.to have_one(:packagist_service) }
@@ -49,11 +49,11 @@ RSpec.describe Project, factory_default: :keep do
it { is_expected.to have_one(:datadog_integration) }
it { is_expected.to have_one(:discord_integration) }
it { is_expected.to have_one(:drone_ci_integration) }
- it { is_expected.to have_one(:emails_on_push_service) }
+ it { is_expected.to have_one(:emails_on_push_integration) }
it { is_expected.to have_one(:pipelines_email_service) }
- it { is_expected.to have_one(:irker_service) }
+ it { is_expected.to have_one(:irker_integration) }
it { is_expected.to have_one(:pivotaltracker_service) }
- it { is_expected.to have_one(:flowdock_service) }
+ it { is_expected.to have_one(:flowdock_integration) }
it { is_expected.to have_one(:assembla_integration) }
it { is_expected.to have_one(:slack_slash_commands_service) }
it { is_expected.to have_one(:mattermost_slash_commands_service) }
@@ -65,8 +65,8 @@ RSpec.describe Project, factory_default: :keep do
it { is_expected.to have_one(:youtrack_service) }
it { is_expected.to have_one(:custom_issue_tracker_integration) }
it { is_expected.to have_one(:bugzilla_integration) }
- it { is_expected.to have_one(:ewm_service) }
- it { is_expected.to have_one(:external_wiki_service) }
+ it { is_expected.to have_one(:ewm_integration) }
+ it { is_expected.to have_one(:external_wiki_integration) }
it { is_expected.to have_one(:confluence_integration) }
it { is_expected.to have_one(:project_feature) }
it { is_expected.to have_one(:project_repository) }
@@ -1661,6 +1661,45 @@ RSpec.describe Project, factory_default: :keep do
end
end
+ describe '.find_by_url' do
+ subject { described_class.find_by_url(url) }
+
+ let_it_be(:project) { create(:project) }
+
+ before do
+ stub_config_setting(host: 'gitlab.com')
+ end
+
+ context 'url is internal' do
+ let(:url) { "https://#{Gitlab.config.gitlab.host}/#{path}" }
+
+ context 'path is recognised as a project path' do
+ let(:path) { project.full_path }
+
+ it { is_expected.to eq(project) }
+
+ it 'returns nil if the path detection throws an error' do
+ expect(Rails.application.routes).to receive(:recognize_path).with(url) { raise ActionController::RoutingError, 'test' }
+
+ expect { subject }.not_to raise_error(ActionController::RoutingError)
+ expect(subject).to be_nil
+ end
+ end
+
+ context 'path is not a project path' do
+ let(:path) { 'probably/missing.git' }
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ context 'url is external' do
+ let(:url) { "https://foo.com/bar/baz.git" }
+
+ it { is_expected.to be_nil }
+ end
+ end
+
context 'repository storage by default' do
let(:project) { build(:project) }
diff --git a/spec/models/remote_mirror_spec.rb b/spec/models/remote_mirror_spec.rb
index d6951b5926e..a64b01967ef 100644
--- a/spec/models/remote_mirror_spec.rb
+++ b/spec/models/remote_mirror_spec.rb
@@ -157,19 +157,34 @@ RSpec.describe RemoteMirror, :mailer do
end
describe '#update_repository' do
- it 'performs update including options' do
- git_remote_mirror = stub_const('Gitlab::Git::RemoteMirror', spy)
- mirror = build(:remote_mirror)
-
- expect(mirror).to receive(:options_for_update).and_return(keep_divergent_refs: true)
- mirror.update_repository
-
- expect(git_remote_mirror).to have_received(:new).with(
- mirror.project.repository.raw,
- mirror.remote_name,
- keep_divergent_refs: true
- )
- expect(git_remote_mirror).to have_received(:update)
+ shared_examples 'an update' do
+ it 'performs update including options' do
+ git_remote_mirror = stub_const('Gitlab::Git::RemoteMirror', spy)
+ mirror = build(:remote_mirror)
+
+ expect(mirror).to receive(:options_for_update).and_return(keep_divergent_refs: true)
+ mirror.update_repository(inmemory_remote: inmemory)
+
+ expect(git_remote_mirror).to have_received(:new).with(
+ mirror.project.repository.raw,
+ mirror.remote_name,
+ inmemory ? mirror.url : nil,
+ keep_divergent_refs: true
+ )
+ expect(git_remote_mirror).to have_received(:update)
+ end
+ end
+
+ context 'with inmemory remote' do
+ let(:inmemory) { true }
+
+ it_behaves_like 'an update'
+ end
+
+ context 'with on-disk remote' do
+ let(:inmemory) { false }
+
+ it_behaves_like 'an update'
end
end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index f1c30a646f5..e5c86e69ffc 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -5224,6 +5224,70 @@ RSpec.describe User do
end
end
+ describe '#password_expired_if_applicable?' do
+ let(:user) { build(:user, password_expires_at: password_expires_at) }
+
+ subject { user.password_expired_if_applicable? }
+
+ context 'when user is not ldap user' do
+ context 'when password_expires_at is not set' do
+ let(:password_expires_at) {}
+
+ it 'returns false' do
+ is_expected.to be_falsey
+ end
+ end
+
+ context 'when password_expires_at is in the past' do
+ let(:password_expires_at) { 1.minute.ago }
+
+ it 'returns true' do
+ is_expected.to be_truthy
+ end
+ end
+
+ context 'when password_expires_at is in the future' do
+ let(:password_expires_at) { 1.minute.from_now }
+
+ it 'returns false' do
+ is_expected.to be_falsey
+ end
+ end
+ end
+
+ context 'when user is ldap user' do
+ let(:user) { build(:user, password_expires_at: password_expires_at) }
+
+ before do
+ allow(user).to receive(:ldap_user?).and_return(true)
+ end
+
+ context 'when password_expires_at is not set' do
+ let(:password_expires_at) {}
+
+ it 'returns false' do
+ is_expected.to be_falsey
+ end
+ end
+
+ context 'when password_expires_at is in the past' do
+ let(:password_expires_at) { 1.minute.ago }
+
+ it 'returns false' do
+ is_expected.to be_falsey
+ end
+ end
+
+ context 'when password_expires_at is in the future' do
+ let(:password_expires_at) { 1.minute.from_now }
+
+ it 'returns false' do
+ is_expected.to be_falsey
+ end
+ end
+ end
+ end
+
describe '#read_only_attribute?' do
context 'when synced attributes metadata is present' do
it 'delegates to synced_attributes_metadata' do
diff --git a/spec/policies/base_policy_spec.rb b/spec/policies/base_policy_spec.rb
index 44ff909872d..ec20616d357 100644
--- a/spec/policies/base_policy_spec.rb
+++ b/spec/policies/base_policy_spec.rb
@@ -22,31 +22,45 @@ RSpec.describe BasePolicy do
end
end
- shared_examples 'admin only access' do |policy|
+ shared_examples 'admin only access' do |ability|
+ def policy
+ # method, because we want a fresh cache each time.
+ described_class.new(current_user, nil)
+ end
+
let(:current_user) { build_stubbed(:user) }
- subject { described_class.new(current_user, nil) }
+ subject { policy }
- it { is_expected.not_to be_allowed(policy) }
+ it { is_expected.not_to be_allowed(ability) }
- context 'for admins' do
+ context 'with an admin' do
let(:current_user) { build_stubbed(:admin) }
it 'allowed when in admin mode' do
enable_admin_mode!(current_user)
- is_expected.to be_allowed(policy)
+ is_expected.to be_allowed(ability)
end
it 'prevented when not in admin mode' do
- is_expected.not_to be_allowed(policy)
+ is_expected.not_to be_allowed(ability)
end
end
- context 'for anonymous' do
+ context 'with anonymous' do
let(:current_user) { nil }
- it { is_expected.not_to be_allowed(policy) }
+ it { is_expected.not_to be_allowed(ability) }
+ end
+
+ describe 'bypassing the session for sessionless login', :request_store do
+ let(:current_user) { build_stubbed(:admin) }
+
+ it 'changes from prevented to allowed' do
+ expect { Gitlab::Auth::CurrentUserMode.bypass_session!(current_user.id) }
+ .to change { policy.allowed?(ability) }.from(false).to(true)
+ end
end
end
diff --git a/spec/policies/global_policy_spec.rb b/spec/policies/global_policy_spec.rb
index 9e995366c17..e88619b9527 100644
--- a/spec/policies/global_policy_spec.rb
+++ b/spec/policies/global_policy_spec.rb
@@ -245,6 +245,14 @@ RSpec.describe GlobalPolicy do
end
it { is_expected.not_to be_allowed(:access_api) }
+
+ context 'when user is using ldap' do
+ before do
+ allow(current_user).to receive(:ldap_user?).and_return(true)
+ end
+
+ it { is_expected.to be_allowed(:access_api) }
+ end
end
context 'when terms are enforced' do
@@ -433,6 +441,14 @@ RSpec.describe GlobalPolicy do
end
it { is_expected.not_to be_allowed(:access_git) }
+
+ context 'when user is using ldap' do
+ before do
+ allow(current_user).to receive(:ldap_user?).and_return(true)
+ end
+
+ it { is_expected.to be_allowed(:access_git) }
+ end
end
end
@@ -517,6 +533,14 @@ RSpec.describe GlobalPolicy do
end
it { is_expected.not_to be_allowed(:use_slash_commands) }
+
+ context 'when user is using ldap' do
+ before do
+ allow(current_user).to receive(:ldap_user?).and_return(true)
+ end
+
+ it { is_expected.to be_allowed(:use_slash_commands) }
+ end
end
end
diff --git a/spec/requests/api/graphql/group_query_spec.rb b/spec/requests/api/graphql/group_query_spec.rb
index 8e4f808f794..b6bbf8d5dd2 100644
--- a/spec/requests/api/graphql/group_query_spec.rb
+++ b/spec/requests/api/graphql/group_query_spec.rb
@@ -96,7 +96,7 @@ RSpec.describe 'getting group information' do
expect(graphql_data['group']).to be_nil
end
- it 'avoids N+1 queries' do
+ it 'avoids N+1 queries', :assume_throttled do
pending('See: https://gitlab.com/gitlab-org/gitlab/-/issues/245272')
queries = [{ query: group_query(group1) },
diff --git a/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb
index bcede4d37dd..a63116e2b94 100644
--- a/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb
+++ b/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Setting assignees of a merge request' do
+RSpec.describe 'Setting assignees of a merge request', :assume_throttled do
include GraphqlHelpers
let_it_be(:project) { create(:project, :repository) }
@@ -68,7 +68,7 @@ RSpec.describe 'Setting assignees of a merge request' do
context 'when the current user does not have permission to add assignees' do
let(:current_user) { create(:user) }
- let(:db_query_limit) { 27 }
+ let(:db_query_limit) { 28 }
it 'does not change the assignees' do
project.add_guest(current_user)
@@ -80,7 +80,7 @@ RSpec.describe 'Setting assignees of a merge request' do
end
context 'with assignees already assigned' do
- let(:db_query_limit) { 39 }
+ let(:db_query_limit) { 46 }
before do
merge_request.assignees = [assignee2]
@@ -96,7 +96,7 @@ RSpec.describe 'Setting assignees of a merge request' do
end
context 'when passing an empty list of assignees' do
- let(:db_query_limit) { 31 }
+ let(:db_query_limit) { 32 }
let(:input) { { assignee_usernames: [] } }
before do
@@ -115,7 +115,7 @@ RSpec.describe 'Setting assignees of a merge request' do
context 'when passing append as true' do
let(:mode) { Types::MutationOperationModeEnum.enum[:append] }
let(:input) { { assignee_usernames: [assignee2.username], operation_mode: mode } }
- let(:db_query_limit) { 20 }
+ let(:db_query_limit) { 22 }
before do
# In CE, APPEND is a NOOP as you can't have multiple assignees
@@ -135,7 +135,7 @@ RSpec.describe 'Setting assignees of a merge request' do
end
context 'when passing remove as true' do
- let(:db_query_limit) { 31 }
+ let(:db_query_limit) { 32 }
let(:mode) { Types::MutationOperationModeEnum.enum[:remove] }
let(:input) { { assignee_usernames: [assignee.username], operation_mode: mode } }
let(:expected_result) { [] }
diff --git a/spec/requests/api/import_bitbucket_server_spec.rb b/spec/requests/api/import_bitbucket_server_spec.rb
index dac139064da..972b21ad2e0 100644
--- a/spec/requests/api/import_bitbucket_server_spec.rb
+++ b/spec/requests/api/import_bitbucket_server_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe API::ImportBitbucketServer do
let(:base_uri) { "https://test:7990" }
- let(:user) { create(:user) }
+ let(:user) { create(:user, bio: 'test') }
let(:token) { "asdasd12345" }
let(:secret) { "sekrettt" }
let(:project_key) { 'TES' }
diff --git a/spec/requests/api/protected_branches_spec.rb b/spec/requests/api/protected_branches_spec.rb
index 6b1aa576167..8efb822cb83 100644
--- a/spec/requests/api/protected_branches_spec.rb
+++ b/spec/requests/api/protected_branches_spec.rb
@@ -228,7 +228,7 @@ RSpec.describe API::ProtectedBranches do
context 'when a policy restricts rule deletion' do
before do
- policy = instance_double(ProtectedBranchPolicy, can?: false)
+ policy = instance_double(ProtectedBranchPolicy, allowed?: false)
expect(ProtectedBranchPolicy).to receive(:new).and_return(policy)
end
@@ -278,7 +278,7 @@ RSpec.describe API::ProtectedBranches do
context 'when a policy restricts rule deletion' do
before do
- policy = instance_double(ProtectedBranchPolicy, can?: false)
+ policy = instance_double(ProtectedBranchPolicy, allowed?: false)
expect(ProtectedBranchPolicy).to receive(:new).and_return(policy)
end
diff --git a/spec/requests/api/services_spec.rb b/spec/requests/api/services_spec.rb
index 8701efcd65f..f7394fa0cb4 100644
--- a/spec/requests/api/services_spec.rb
+++ b/spec/requests/api/services_spec.rb
@@ -25,8 +25,8 @@ RSpec.describe API::Services do
end
context 'project with services' do
- let!(:active_service) { create(:emails_on_push_service, project: project, active: true) }
- let!(:service) { create(:custom_issue_tracker_integration, project: project, active: false) }
+ let!(:active_integration) { create(:emails_on_push_integration, project: project, active: true) }
+ let!(:integration) { create(:custom_issue_tracker_integration, project: project, active: false) }
it "returns a list of all active services" do
get api("/projects/#{project.id}/services", user)
@@ -317,7 +317,7 @@ RSpec.describe API::Services do
end
before do
- project.create_hangouts_chat_service(
+ project.create_hangouts_chat_integration(
active: true,
properties: params
)
diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb
index 7cf46f6adc6..ec55810b4ad 100644
--- a/spec/requests/git_http_spec.rb
+++ b/spec/requests/git_http_spec.rb
@@ -36,16 +36,6 @@ RSpec.describe 'Git HTTP requests' do
end
end
- context "when password is expired" do
- it "responds to downloads with status 401 Unauthorized" do
- user.update!(password_expires_at: 2.days.ago)
-
- download(path, user: user.username, password: user.password) do |response|
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
- end
- end
-
context "when user is blocked" do
let(:user) { create(:user, :blocked) }
@@ -68,6 +58,26 @@ RSpec.describe 'Git HTTP requests' do
end
end
+ shared_examples 'operations are not allowed with expired password' do
+ context "when password is expired" do
+ it "responds to downloads with status 401 Unauthorized" do
+ user.update!(password_expires_at: 2.days.ago)
+
+ download(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ it "responds to uploads with status 401 Unauthorized" do
+ user.update!(password_expires_at: 2.days.ago)
+
+ upload(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+ end
+ end
+
shared_examples 'pushes require Basic HTTP Authentication' do
context "when no credentials are provided" do
it "responds to uploads with status 401 Unauthorized (no project existence information leak)" do
@@ -95,15 +105,6 @@ RSpec.describe 'Git HTTP requests' do
expect(response.header['WWW-Authenticate']).to start_with('Basic ')
end
end
-
- context "when password is expired" do
- it "responds to uploads with status 401 Unauthorized" do
- user.update!(password_expires_at: 2.days.ago)
- upload(path, user: user.username, password: user.password) do |response|
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
- end
- end
end
context "when authentication succeeds" do
@@ -212,6 +213,7 @@ RSpec.describe 'Git HTTP requests' do
it_behaves_like 'pulls require Basic HTTP Authentication'
it_behaves_like 'pushes require Basic HTTP Authentication'
+ it_behaves_like 'operations are not allowed with expired password'
context 'when authenticated' do
it 'rejects downloads and uploads with 404 Not Found' do
@@ -306,6 +308,7 @@ RSpec.describe 'Git HTTP requests' do
it_behaves_like 'pulls require Basic HTTP Authentication'
it_behaves_like 'pushes require Basic HTTP Authentication'
+ it_behaves_like 'operations are not allowed with expired password'
context 'when authenticated' do
context 'and as a developer on the team' do
@@ -473,6 +476,7 @@ RSpec.describe 'Git HTTP requests' do
it_behaves_like 'pulls require Basic HTTP Authentication'
it_behaves_like 'pushes require Basic HTTP Authentication'
+ it_behaves_like 'operations are not allowed with expired password'
end
context 'but the repo is enabled' do
@@ -488,6 +492,7 @@ RSpec.describe 'Git HTTP requests' do
it_behaves_like 'pulls require Basic HTTP Authentication'
it_behaves_like 'pushes require Basic HTTP Authentication'
+ it_behaves_like 'operations are not allowed with expired password'
end
end
@@ -508,6 +513,7 @@ RSpec.describe 'Git HTTP requests' do
it_behaves_like 'pulls require Basic HTTP Authentication'
it_behaves_like 'pushes require Basic HTTP Authentication'
+ it_behaves_like 'operations are not allowed with expired password'
context "when username and password are provided" do
let(:env) { { user: user.username, password: 'nope' } }
@@ -1003,6 +1009,24 @@ RSpec.describe 'Git HTTP requests' do
it_behaves_like 'pulls are allowed'
it_behaves_like 'pushes are allowed'
+
+ context "when password is expired" do
+ it "responds to downloads with status 200" do
+ user.update!(password_expires_at: 2.days.ago)
+
+ download(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ it "responds to uploads with status 200" do
+ user.update!(password_expires_at: 2.days.ago)
+
+ upload(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
end
end
end
diff --git a/spec/serializers/merge_request_poll_widget_entity_spec.rb b/spec/serializers/merge_request_poll_widget_entity_spec.rb
index c88555226a9..9a0e25516cb 100644
--- a/spec/serializers/merge_request_poll_widget_entity_spec.rb
+++ b/spec/serializers/merge_request_poll_widget_entity_spec.rb
@@ -229,21 +229,13 @@ RSpec.describe MergeRequestPollWidgetEntity do
expect(subject[:mergeable]).to eq(true)
end
- context 'when async_mergeability_check is passed' do
- let(:options) { { async_mergeability_check: true } }
-
- it 'returns false' do
- expect(subject[:mergeable]).to eq(false)
+ context 'when check_mergeability_async_in_widget is disabled' do
+ before do
+ stub_feature_flags(check_mergeability_async_in_widget: false)
end
- context 'when check_mergeability_async_in_widget is disabled' do
- before do
- stub_feature_flags(check_mergeability_async_in_widget: false)
- end
-
- it 'calculates mergeability and returns true' do
- expect(subject[:mergeable]).to eq(true)
- end
+ it 'calculates mergeability and returns true' do
+ expect(subject[:mergeable]).to eq(true)
end
end
end
diff --git a/spec/serializers/service_event_entity_spec.rb b/spec/serializers/service_event_entity_spec.rb
index 64baa57fd6d..91254c7dd27 100644
--- a/spec/serializers/service_event_entity_spec.rb
+++ b/spec/serializers/service_event_entity_spec.rb
@@ -5,15 +5,15 @@ require 'spec_helper'
RSpec.describe ServiceEventEntity do
let(:request) { double('request') }
- subject { described_class.new(event, request: request, service: service).as_json }
+ subject { described_class.new(event, request: request, service: integration).as_json }
before do
- allow(request).to receive(:service).and_return(service)
+ allow(request).to receive(:service).and_return(integration)
end
describe '#as_json' do
context 'service without fields' do
- let(:service) { create(:emails_on_push_service, push_events: true) }
+ let(:integration) { create(:emails_on_push_integration, push_events: true) }
let(:event) { 'push' }
it 'exposes correct attributes' do
@@ -25,7 +25,7 @@ RSpec.describe ServiceEventEntity do
end
context 'service with fields' do
- let(:service) { create(:slack_service, note_events: false, note_channel: 'note-channel') }
+ let(:integration) { create(:slack_service, note_events: false, note_channel: 'note-channel') }
let(:event) { 'note' }
it 'exposes correct attributes' do
diff --git a/spec/serializers/service_field_entity_spec.rb b/spec/serializers/service_field_entity_spec.rb
index 007042e1087..20ca98416f8 100644
--- a/spec/serializers/service_field_entity_spec.rb
+++ b/spec/serializers/service_field_entity_spec.rb
@@ -55,10 +55,11 @@ RSpec.describe ServiceFieldEntity do
end
context 'EmailsOnPush Service' do
- let(:service) { create(:emails_on_push_service, send_from_committer_email: '1') }
+ let(:integration) { create(:emails_on_push_integration, send_from_committer_email: '1') }
+ let(:service) { integration } # TODO: remove when https://gitlab.com/gitlab-org/gitlab/-/issues/330300 is complete
context 'field with type checkbox' do
- let(:field) { service.global_fields.find { |field| field[:name] == 'send_from_committer_email' } }
+ let(:field) { integration.global_fields.find { |field| field[:name] == 'send_from_committer_email' } }
it 'exposes correct attributes and casts value to Boolean' do
expected_hash = {
@@ -77,7 +78,7 @@ RSpec.describe ServiceFieldEntity do
end
context 'field with type select' do
- let(:field) { service.global_fields.find { |field| field[:name] == 'branches_to_be_notified' } }
+ let(:field) { integration.global_fields.find { |field| field[:name] == 'branches_to_be_notified' } }
it 'exposes correct attributes' do
expected_hash = {
diff --git a/spec/services/ci/create_pipeline_service/cross_project_pipeline_spec.rb b/spec/services/ci/create_pipeline_service/cross_project_pipeline_spec.rb
index 7fd32288893..b3b8e34dd8e 100644
--- a/spec/services/ci/create_pipeline_service/cross_project_pipeline_spec.rb
+++ b/spec/services/ci/create_pipeline_service/cross_project_pipeline_spec.rb
@@ -53,8 +53,6 @@ RSpec.describe Ci::CreatePipelineService, '#execute' do
end
context 'when sidekiq processes the job', :sidekiq_inline do
- let_it_be(:runner) { create(:ci_runner, :online) }
-
it 'transitions to pending status and triggers a downstream pipeline' do
pipeline = create_pipeline!
diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb
index 052727401dd..3316f8c3d9b 100644
--- a/spec/services/ci/create_pipeline_service_spec.rb
+++ b/spec/services/ci/create_pipeline_service_spec.rb
@@ -7,7 +7,6 @@ RSpec.describe Ci::CreatePipelineService do
let_it_be(:project, reload: true) { create(:project, :repository) }
let_it_be(:user, reload: true) { project.owner }
- let_it_be(:runner) { create(:ci_runner, :online, tag_list: %w[postgres mysql ruby]) }
let(:ref_name) { 'refs/heads/master' }
diff --git a/spec/services/ci/pipeline_processing/shared_processing_service.rb b/spec/services/ci/pipeline_processing/shared_processing_service.rb
index 34d9b60217f..13c924a3089 100644
--- a/spec/services/ci/pipeline_processing/shared_processing_service.rb
+++ b/spec/services/ci/pipeline_processing/shared_processing_service.rb
@@ -859,8 +859,6 @@ RSpec.shared_examples 'Pipeline Processing Service' do
end
context 'when a bridge job has parallel:matrix config', :sidekiq_inline do
- let_it_be(:runner) { create(:ci_runner, :online) }
-
let(:parent_config) do
<<-EOY
test:
diff --git a/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb b/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb
index 9c8e6fd3292..572808cd2db 100644
--- a/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb
+++ b/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb
@@ -3,7 +3,6 @@
RSpec.shared_context 'Pipeline Processing Service Tests With Yaml' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { project.owner }
- let_it_be(:runner) { create(:ci_runner, :online) }
where(:test_file_path) do
Dir.glob(Rails.root.join('spec/services/ci/pipeline_processing/test_cases/*.yml'))
diff --git a/spec/services/environments/canary_ingress/update_service_spec.rb b/spec/services/environments/canary_ingress/update_service_spec.rb
index 0e72fff1ed2..531f7d68a9f 100644
--- a/spec/services/environments/canary_ingress/update_service_spec.rb
+++ b/spec/services/environments/canary_ingress/update_service_spec.rb
@@ -32,16 +32,6 @@ RSpec.describe Environments::CanaryIngress::UpdateService, :clean_gitlab_redis_c
let(:params) { { weight: 50 } }
let(:canary_ingress) { ::Gitlab::Kubernetes::Ingress.new(kube_ingress(track: :canary)) }
- context 'when canary_ingress_weight_control feature flag is disabled' do
- before do
- stub_feature_flags(canary_ingress_weight_control: false)
- end
-
- it_behaves_like 'failed request' do
- let(:message) { "Feature flag is not enabled on the environment's project." }
- end
- end
-
context 'when the actor does not have permission to update environment' do
let(:user) { reporter }
diff --git a/spec/services/packages/helm/extract_file_metadata_service_spec.rb b/spec/services/packages/helm/extract_file_metadata_service_spec.rb
index ea196190e24..273f679b736 100644
--- a/spec/services/packages/helm/extract_file_metadata_service_spec.rb
+++ b/spec/services/packages/helm/extract_file_metadata_service_spec.rb
@@ -38,9 +38,7 @@ RSpec.describe Packages::Helm::ExtractFileMetadataService do
context 'with Chart.yaml at root' do
before do
expect_next_instances_of(Gem::Package::TarReader::Entry, 14) do |entry|
- expect(entry).to receive(:full_name).exactly(:once) do
- 'Chart.yaml'
- end
+ expect(entry).to receive(:full_name).exactly(:once).and_return('Chart.yaml')
end
end
diff --git a/spec/services/packages/helm/process_file_service_spec.rb b/spec/services/packages/helm/process_file_service_spec.rb
new file mode 100644
index 00000000000..2e98590a4f4
--- /dev/null
+++ b/spec/services/packages/helm/process_file_service_spec.rb
@@ -0,0 +1,107 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe Packages::Helm::ProcessFileService do
+ let(:package) { create(:helm_package, without_package_files: true, status: 'processing')}
+ let!(:package_file) { create(:helm_package_file, without_loaded_metadatum: true, package: package) }
+ let(:channel) { 'stable' }
+ let(:service) { described_class.new(channel, package_file) }
+
+ let(:expected) do
+ {
+ 'apiVersion' => 'v2',
+ 'description' => 'File, Block, and Object Storage Services for your Cloud-Native Environment',
+ 'icon' => 'https://rook.io/images/rook-logo.svg',
+ 'name' => 'rook-ceph',
+ 'sources' => ['https://github.com/rook/rook'],
+ 'version' => 'v1.5.8'
+ }
+ end
+
+ describe '#execute' do
+ subject(:execute) { service.execute }
+
+ context 'without a file' do
+ let(:package_file) { nil }
+
+ it 'returns error', :aggregate_failures do
+ expect { execute }
+ .to not_change { Packages::Package.count }
+ .and not_change { Packages::PackageFile.count }
+ .and not_change { Packages::Helm::FileMetadatum.count }
+ .and raise_error(Packages::Helm::ProcessFileService::ExtractionError, 'Helm chart was not processed - package_file is not set')
+ end
+ end
+
+ context 'with existing package' do
+ let!(:existing_package) { create(:helm_package, project: package.project, name: 'rook-ceph', version: 'v1.5.8') }
+
+ it 'reuses existing package', :aggregate_failures do
+ expect { execute }
+ .to change { Packages::Package.count }.from(2).to(1)
+ .and not_change { package.name }
+ .and not_change { package.version }
+ .and not_change { package.status }
+ .and not_change { Packages::PackageFile.count }
+ .and change { package_file.file_name }.from(package_file.file_name).to("#{expected['name']}-#{expected['version']}.tgz")
+ .and change { Packages::Helm::FileMetadatum.count }.from(1).to(2)
+ .and change { package_file.helm_file_metadatum }.from(nil)
+
+ expect { package.reload }
+ .to raise_error(ActiveRecord::RecordNotFound)
+
+ expect(package_file.helm_file_metadatum.channel).to eq(channel)
+ expect(package_file.helm_file_metadatum.metadata).to eq(expected)
+ end
+ end
+
+ context 'with a valid file' do
+ it 'processes file', :aggregate_failures do
+ expect { execute }
+ .to not_change { Packages::Package.count }
+ .and change { package.name }.from(package.name).to(expected['name'])
+ .and change { package.version }.from(package.version).to(expected['version'])
+ .and change { package.status }.from('processing').to('default')
+ .and not_change { Packages::PackageFile.count }
+ .and change { package_file.file_name }.from(package_file.file_name).to("#{expected['name']}-#{expected['version']}.tgz")
+ .and change { Packages::Helm::FileMetadatum.count }.by(1)
+ .and change { package_file.helm_file_metadatum }.from(nil)
+
+ expect(package_file.helm_file_metadatum.channel).to eq(channel)
+ expect(package_file.helm_file_metadatum.metadata).to eq(expected)
+ end
+ end
+
+ context 'without Chart.yaml' do
+ before do
+ expect_next_instances_of(Gem::Package::TarReader::Entry, 14) do |entry|
+ expect(entry).to receive(:full_name).exactly(:once).and_wrap_original do |m, *args|
+ m.call(*args) + '_suffix'
+ end
+ end
+ end
+
+ it { expect { execute }.to raise_error(Packages::Helm::ExtractFileMetadataService::ExtractionError, 'Chart.yaml not found within a directory') }
+ end
+
+ context 'with Chart.yaml at root' do
+ before do
+ expect_next_instances_of(Gem::Package::TarReader::Entry, 14) do |entry|
+ expect(entry).to receive(:full_name).exactly(:once).and_return('Chart.yaml')
+ end
+ end
+
+ it { expect { execute }.to raise_error(Packages::Helm::ExtractFileMetadataService::ExtractionError, 'Chart.yaml not found within a directory') }
+ end
+
+ context 'with an invalid YAML' do
+ before do
+ expect_next_instance_of(Gem::Package::TarReader::Entry) do |entry|
+ expect(entry).to receive(:read).and_return('{')
+ end
+ end
+
+ it { expect { execute }.to raise_error(Packages::Helm::ExtractFileMetadataService::ExtractionError, 'Error while parsing Chart.yaml: (<unknown>): did not find expected node content while parsing a flow node at line 2 column 1') }
+ end
+ end
+end
diff --git a/spec/services/projects/update_remote_mirror_service_spec.rb b/spec/services/projects/update_remote_mirror_service_spec.rb
index 96dbfe8e0b7..feb70ddaa46 100644
--- a/spec/services/projects/update_remote_mirror_service_spec.rb
+++ b/spec/services/projects/update_remote_mirror_service_spec.rb
@@ -13,21 +13,36 @@ RSpec.describe Projects::UpdateRemoteMirrorService do
describe '#execute' do
let(:retries) { 0 }
+ let(:inmemory) { true }
subject(:execute!) { service.execute(remote_mirror, retries) }
before do
+ stub_feature_flags(update_remote_mirror_inmemory: inmemory)
project.repository.add_branch(project.owner, 'existing-branch', 'master')
allow(remote_mirror)
.to receive(:update_repository)
+ .with(inmemory_remote: inmemory)
.and_return(double(divergent_refs: []))
end
- it 'ensures the remote exists' do
- expect(remote_mirror).to receive(:ensure_remote!)
+ context 'with in-memory remote disabled' do
+ let(:inmemory) { false }
- execute!
+ it 'ensures the remote exists' do
+ expect(remote_mirror).to receive(:ensure_remote!)
+
+ execute!
+ end
+ end
+
+ context 'with in-memory remote enabled' do
+ it 'does not ensure the remote exists' do
+ expect(remote_mirror).not_to receive(:ensure_remote!)
+
+ execute!
+ end
end
it 'does not fetch the remote repository' do
diff --git a/spec/services/protected_branches/create_service_spec.rb b/spec/services/protected_branches/create_service_spec.rb
index 986322e4d87..45462831a31 100644
--- a/spec/services/protected_branches/create_service_spec.rb
+++ b/spec/services/protected_branches/create_service_spec.rb
@@ -40,7 +40,7 @@ RSpec.describe ProtectedBranches::CreateService do
context 'when a policy restricts rule creation' do
before do
- policy = instance_double(ProtectedBranchPolicy, can?: false)
+ policy = instance_double(ProtectedBranchPolicy, allowed?: false)
expect(ProtectedBranchPolicy).to receive(:new).and_return(policy)
end
diff --git a/spec/services/protected_branches/destroy_service_spec.rb b/spec/services/protected_branches/destroy_service_spec.rb
index 98d31147754..47a048e7033 100644
--- a/spec/services/protected_branches/destroy_service_spec.rb
+++ b/spec/services/protected_branches/destroy_service_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe ProtectedBranches::DestroyService do
context 'when a policy restricts rule deletion' do
before do
- policy = instance_double(ProtectedBranchPolicy, can?: false)
+ policy = instance_double(ProtectedBranchPolicy, allowed?: false)
expect(ProtectedBranchPolicy).to receive(:new).and_return(policy)
end
diff --git a/spec/services/protected_branches/update_service_spec.rb b/spec/services/protected_branches/update_service_spec.rb
index fdfbdf2e6ae..88e58ad5907 100644
--- a/spec/services/protected_branches/update_service_spec.rb
+++ b/spec/services/protected_branches/update_service_spec.rb
@@ -27,7 +27,7 @@ RSpec.describe ProtectedBranches::UpdateService do
context 'when a policy restricts rule creation' do
before do
- policy = instance_double(ProtectedBranchPolicy, can?: false)
+ policy = instance_double(ProtectedBranchPolicy, allowed?: false)
expect(ProtectedBranchPolicy).to receive(:new).and_return(policy)
end
diff --git a/spec/services/repositories/changelog_service_spec.rb b/spec/services/repositories/changelog_service_spec.rb
index 02d60f076ca..9a5b0f33fbb 100644
--- a/spec/services/repositories/changelog_service_spec.rb
+++ b/spec/services/repositories/changelog_service_spec.rb
@@ -76,7 +76,7 @@ RSpec.describe Repositories::ChangelogService do
recorder = ActiveRecord::QueryRecorder.new { service.execute }
changelog = project.repository.blob_at('master', 'CHANGELOG.md')&.data
- expect(recorder.count).to eq(11)
+ expect(recorder.count).to eq(12)
expect(changelog).to include('Title 1', 'Title 2')
end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 9a2eee0edc5..31ff619232c 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -346,6 +346,15 @@ RSpec.configure do |config|
Gitlab::WithRequestStore.with_request_store { example.run }
end
+ # previous test runs may have left some resources throttled
+ config.before do
+ ::Gitlab::ExclusiveLease.reset_all!("el:throttle:*")
+ end
+
+ config.before(:example, :assume_throttled) do |example|
+ allow(::Gitlab::ExclusiveLease).to receive(:throttle).and_return(nil)
+ end
+
config.before(:example, :request_store) do
# Clear request store before actually starting the spec (the
# `around` above will have the request store enabled for all
diff --git a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
index c775574091e..a1aa7c04b67 100644
--- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
+++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
@@ -1061,7 +1061,7 @@ RSpec.describe 'layouts/nav/sidebar/_project' do
let(:service_status) { true }
before do
- project.create_external_wiki_service(active: service_status, properties: properties)
+ project.create_external_wiki_integration(active: service_status, properties: properties)
project.reload
end
diff --git a/spec/workers/container_expiration_policy_worker_spec.rb b/spec/workers/container_expiration_policy_worker_spec.rb
index 8562935b0b5..6f81d06f653 100644
--- a/spec/workers/container_expiration_policy_worker_spec.rb
+++ b/spec/workers/container_expiration_policy_worker_spec.rb
@@ -123,5 +123,19 @@ RSpec.describe ContainerExpirationPolicyWorker do
expect(stuck_cleanup.reload).to be_cleanup_unfinished
end
end
+
+ context 'policies without container repositories' do
+ let_it_be(:container_expiration_policy1) { create(:container_expiration_policy, enabled: true) }
+ let_it_be(:container_repository1) { create(:container_repository, project_id: container_expiration_policy1.project_id) }
+ let_it_be(:container_expiration_policy2) { create(:container_expiration_policy, enabled: true) }
+ let_it_be(:container_repository2) { create(:container_repository, project_id: container_expiration_policy2.project_id) }
+ let_it_be(:container_expiration_policy3) { create(:container_expiration_policy, enabled: true) }
+
+ it 'disables them' do
+ expect { subject }
+ .to change { ::ContainerExpirationPolicy.active.count }.from(3).to(2)
+ expect(container_expiration_policy3.reload.enabled).to be false
+ end
+ end
end
end
diff --git a/spec/workers/web_hook_worker_spec.rb b/spec/workers/web_hook_worker_spec.rb
index 548cf4c717a..a86964aa417 100644
--- a/spec/workers/web_hook_worker_spec.rb
+++ b/spec/workers/web_hook_worker_spec.rb
@@ -17,7 +17,6 @@ RSpec.describe WebHookWorker do
it_behaves_like 'worker with data consistency',
described_class,
- feature_flag: :load_balancing_for_web_hook_worker,
data_consistency: :delayed
end
end