From 143a33345cf3607ad35ec31130cec4922bc1113c Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Tue, 1 Feb 2022 18:17:05 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- .../javascripts/diffs/components/app.vue | 4 +- .../groups/components/transfer_group_form.vue | 3 - .../groups/init_transfer_group_form.js | 6 +- .../mr_widget_options.vue | 2 +- .../namespace_select/namespace_select.vue | 19 ++- .../projects/merge_requests_controller.rb | 2 +- app/graphql/mutations/work_items/delete.rb | 48 ++++++ app/graphql/types/mutation_type.rb | 1 + app/policies/work_item_policy.rb | 7 + app/services/projects/destroy_service.rb | 5 +- app/services/work_items/delete_service.rb | 17 +++ ...ge_request_merge_checks_settings.html.haml | 4 +- app/views/projects/notes/_actions.html.haml | 30 ---- .../shared/icons/_icon_resolve_discussion.svg | 1 - .../icons/_icon_status_success_solid.svg | 1 - .../extract_mr_diff_commit_deletions.yml | 8 - ..._data.yml => usage_data_diff_searches.yml} | 2 +- config/initializers/postgres_partitioning.rb | 3 + config/initializers/rubyzip.rb | 4 + .../package_information/supported_os.md | 4 +- doc/api/graphql/reference/index.md | 21 +++ .../service_ping/metrics_dictionary.md | 18 ++- doc/subscriptions/gitlab_com/index.md | 10 +- doc/topics/release_your_application.md | 4 +- doc/user/clusters/agent/index.md | 2 +- .../project/merge_requests/approvals/rules.md | 13 ++ lib/gitlab/database/each_database.rb | 32 +++- lib/gitlab/database/partitioning.rb | 25 ++- .../partitioning/partition_manager.rb | 27 +++- lib/gitlab/database/shared_model.rb | 4 + lib/gitlab/popen.rb | 11 +- .../known_events/code_review_events.yml | 2 +- lib/learn_gitlab/project.rb | 3 +- locale/gitlab.pot | 6 +- package.json | 2 +- qa/qa/page/main/login.rb | 1 + qa/qa/page/merge_request/show.rb | 40 ++++- .../migration/gitlab_migration_mr_spec.rb | 6 +- .../merge_when_pipeline_succeeds_spec.rb | 73 ++++++--- scripts/rspec_bisect_flaky | 10 +- .../add_context_commits_modal_spec.js.snap | 1 - .../__snapshots__/popover_spec.js.snap | 1 - .../incidents_settings_tabs_spec.js.snap | 1 - .../namespace_select/namespace_select_spec.js | 142 ++++++++++++------ .../lib/gitlab/database/each_database_spec.rb | 108 +++++++++---- spec/lib/gitlab/popen_spec.rb | 11 ++ spec/lib/learn_gitlab/project_spec.rb | 7 + .../mutations/work_items/delete_spec.rb | 49 ++++++ .../pages/zip_directory_service_spec.rb | 6 +- .../services/projects/destroy_service_spec.rb | 25 +-- .../work_items/delete_service_spec.rb | 50 ++++++ yarn.lock | 8 +- 52 files changed, 641 insertions(+), 249 deletions(-) create mode 100644 app/graphql/mutations/work_items/delete.rb create mode 100644 app/services/work_items/delete_service.rb delete mode 100644 app/views/shared/icons/_icon_resolve_discussion.svg delete mode 100644 app/views/shared/icons/_icon_status_success_solid.svg delete mode 100644 config/feature_flags/development/extract_mr_diff_commit_deletions.yml rename config/feature_flags/development/{diff_searching_usage_data.yml => usage_data_diff_searches.yml} (79%) create mode 100644 config/initializers/rubyzip.rb create mode 100644 spec/requests/api/graphql/mutations/work_items/delete_spec.rb create mode 100644 spec/services/work_items/delete_service_spec.rb diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 45f8676a1d2..a8ca17ab4dd 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -524,7 +524,7 @@ export default { if ( window.gon?.features?.diffsVirtualScrolling || - window.gon?.features?.diffSearchingUsageData + window.gon?.features?.usageDataDiffSearches ) { let keydownTime; Mousetrap.bind(['mod+f', 'mod+g'], () => { @@ -540,7 +540,7 @@ export default { if (delta >= 0 && delta < 1000) { this.disableVirtualScroller(); - if (window.gon?.features?.diffSearchingUsageData) { + if (window.gon?.features?.usageDataDiffSearches) { api.trackRedisHllUserEvent('i_code_review_user_searches_diff'); api.trackRedisCounterEvent('diff_searches'); } diff --git a/app/assets/javascripts/groups/components/transfer_group_form.vue b/app/assets/javascripts/groups/components/transfer_group_form.vue index ba8de2b0203..2d0d6b41c99 100644 --- a/app/assets/javascripts/groups/components/transfer_group_form.vue +++ b/app/assets/javascripts/groups/components/transfer_group_form.vue @@ -43,9 +43,6 @@ export default { }; }, computed: { - selectedNamespaceId() { - return this.selectedId; - }, disableSubmitButton() { return this.isPaidGroup || !this.selectedId; }, diff --git a/app/assets/javascripts/groups/init_transfer_group_form.js b/app/assets/javascripts/groups/init_transfer_group_form.js index c60255e6ec3..c9a1e46de72 100644 --- a/app/assets/javascripts/groups/init_transfer_group_form.js +++ b/app/assets/javascripts/groups/init_transfer_group_form.js @@ -4,6 +4,10 @@ import { parseBoolean } from '~/lib/utils/common_utils'; import TransferGroupForm, { i18n } from './components/transfer_group_form.vue'; const prepareGroups = (rawGroups) => { + if (!rawGroups) { + return { group: [] }; + } + const group = JSON.parse(rawGroups).map(({ id, text: humanName }) => ({ id, humanName, @@ -22,7 +26,7 @@ export default () => { targetFormId = null, buttonText: confirmButtonText = '', groupName = '', - parentGroups = [], + parentGroups, isPaidGroup, } = el.dataset; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 6f61afe6e68..11de58aa344 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -578,7 +578,7 @@ export default { :endpoint="mr.accessibilityReportPath" /> -
+
- data.filter((d) => d.humanName.toLowerCase().includes(searchTerm)); +const filterByName = (data, searchTerm = '') => { + if (!searchTerm) { + return data; + } + + return data.filter((d) => d.humanName.toLowerCase().includes(searchTerm.toLowerCase())); +}; export default { name: 'NamespaceSelect', @@ -85,7 +90,15 @@ export default { }, filteredEmptyNamespaceTitle() { const { includeEmptyNamespace, emptyNamespaceTitle, searchTerm } = this; - return includeEmptyNamespace && emptyNamespaceTitle.toLowerCase().includes(searchTerm); + + if (!includeEmptyNamespace) { + return ''; + } + if (!searchTerm) { + return emptyNamespaceTitle; + } + + return emptyNamespaceTitle.toLowerCase().includes(searchTerm.toLowerCase()); }, }, methods: { diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index bb951a54aeb..9007db05431 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -47,7 +47,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo # Usage data feature flags push_frontend_feature_flag(:users_expanding_widgets_usage_data, @project, default_enabled: :yaml) push_frontend_feature_flag(:diff_settings_usage_data, default_enabled: :yaml) - push_frontend_feature_flag(:diff_searching_usage_data, @project, default_enabled: :yaml) + push_frontend_feature_flag(:usage_data_diff_searches, @project, default_enabled: :yaml) end around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions] diff --git a/app/graphql/mutations/work_items/delete.rb b/app/graphql/mutations/work_items/delete.rb new file mode 100644 index 00000000000..6a3e651bdd5 --- /dev/null +++ b/app/graphql/mutations/work_items/delete.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Mutations + module WorkItems + class Delete < BaseMutation + description "Deletes a work item." \ + " Available only when feature flag `work_items` is enabled. The feature is experimental and is subject to change without notice." + + graphql_name 'WorkItemDelete' + + authorize :delete_work_item + + argument :id, ::Types::GlobalIDType[::WorkItem], + required: true, + description: 'Global ID of the work item.' + + field :project, Types::ProjectType, + null: true, + description: 'Project the deleted work item belonged to.' + + def resolve(id:) + work_item = authorized_find!(id: id) + + unless Feature.enabled?(:work_items, work_item.project) + return { errors: ['`work_items` feature flag disabled for this project'] } + end + + result = ::WorkItems::DeleteService.new( + project: work_item.project, + current_user: current_user + ).execute(work_item) + + { + project: result.success? ? work_item.project : nil, + errors: result.errors + } + end + + private + + def find_object(id:) + # TODO: Remove coercion when working on https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = ::Types::GlobalIDType[::WorkItem].coerce_isolated_input(id) + GitlabSchema.find_by_gid(id) + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 021a023cb88..bcf64b79a85 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -126,6 +126,7 @@ module Types mount_mutation Mutations::Packages::DestroyFile mount_mutation Mutations::Echo mount_mutation Mutations::WorkItems::Create, feature_flag: :work_items + mount_mutation Mutations::WorkItems::Delete mount_mutation Mutations::WorkItems::Update end end diff --git a/app/policies/work_item_policy.rb b/app/policies/work_item_policy.rb index 9cacd495e6c..7ba5102a406 100644 --- a/app/policies/work_item_policy.rb +++ b/app/policies/work_item_policy.rb @@ -2,4 +2,11 @@ class WorkItemPolicy < BasePolicy delegate { @subject.project } + + desc 'User is author of the work item' + condition(:author) do + @user && @user == @subject.author + end + + rule { can?(:owner_access) | author }.enable :delete_work_item end diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 5f777a74adb..733a4b45cb2 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -139,10 +139,7 @@ module Projects destroy_web_hooks! destroy_project_bots! destroy_ci_records! - - if ::Feature.enabled?(:extract_mr_diff_commit_deletions, default_enabled: :yaml) - destroy_mr_diff_commits! - end + destroy_mr_diff_commits! # Rails attempts to load all related records into memory before # destroying: https://github.com/rails/rails/issues/22510 diff --git a/app/services/work_items/delete_service.rb b/app/services/work_items/delete_service.rb new file mode 100644 index 00000000000..1093a403a1c --- /dev/null +++ b/app/services/work_items/delete_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module WorkItems + class DeleteService < Issuable::DestroyService + def execute(work_item) + unless current_user.can?(:delete_work_item, work_item) + return ::ServiceResponse.error(message: 'User not authorized to delete work item') + end + + if super + ::ServiceResponse.success + else + ::ServiceResponse.error(message: work_item.errors.full_messages) + end + end + end +end diff --git a/app/views/projects/_merge_request_merge_checks_settings.html.haml b/app/views/projects/_merge_request_merge_checks_settings.html.haml index b9ddb93c664..4f9af40f711 100644 --- a/app/views/projects/_merge_request_merge_checks_settings.html.haml +++ b/app/views/projects/_merge_request_merge_checks_settings.html.haml @@ -8,9 +8,7 @@ = form.label :only_allow_merge_if_pipeline_succeeds, class: 'form-check-label' do = s_('ProjectSettings|Pipelines must succeed') .text-secondary - - configuring_pipelines_for_merge_requests_help_link_url = help_page_path('ci/pipelines/merge_request_pipelines.md', anchor: 'prerequisites') - - configuring_pipelines_for_merge_requests_help_link_start = ''.html_safe % { url: configuring_pipelines_for_merge_requests_help_link_url } - = s_('ProjectSettings|To enable this feature, configure pipelines. %{link_start}How to configure merge request pipelines?%{link_end}').html_safe % { link_start: configuring_pipelines_for_merge_requests_help_link_start, link_end: ''.html_safe } + = s_("ProjectSettings|Merge requests can't be merged if the latest pipeline did not succeed or is still running.") .form-check.mb-2 .gl-pl-6 = form.check_box :allow_merge_on_skipped_pipeline, class: 'form-check-input' diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml index d11b61466e2..31c14aaad50 100644 --- a/app/views/projects/notes/_actions.html.haml +++ b/app/views/projects/notes/_actions.html.haml @@ -6,36 +6,6 @@ - elsif note.contributor? %span{ class: 'note-role user-access-role has-tooltip', title: _("This user has previously committed to the %{name} project.") % { name: note.project_name } }= _("Contributor") -- if note.resolvable? - - can_resolve = can?(current_user, :resolve_note, note) - %resolve-btn{ "project-path" => project_path(note.project), - "discussion-id" => note.discussion_id(@noteable), - ":note-id" => note.id, - ":resolved" => note.resolved?, - ":can-resolve" => can_resolve, - ":author-name" => "'#{j(note.author.name)}'", - "author-avatar" => note.author.avatar_url, - ":note-truncated" => "'#{j(truncate(note.note, length: 17))}'", - ":resolved-by" => "'#{j(note.resolved_by.try(:name))}'", - "v-show" => "#{can_resolve || note.resolved?}", - "inline-template" => true, - "ref" => "note_#{note.id}" } - - .note-actions-item - %button.note-action-button.line-resolve-btn{ type: "button", - class: ("is-disabled" unless can_resolve), - ":class" => "{ 'is-active': isResolved }", - ":aria-label" => "buttonText", - "@click" => "resolve", - ":title" => "buttonText", - ":ref" => "'button'" } - - %div - %template{ 'v-if' => 'isResolved' } - = render 'shared/icons/icon_status_success_solid.svg' - %template{ 'v-else' => '' } - = render 'shared/icons/icon_resolve_discussion.svg' - - if can?(current_user, :award_emoji, note) - if note.emoji_awardable? .note-actions-item diff --git a/app/views/shared/icons/_icon_resolve_discussion.svg b/app/views/shared/icons/_icon_resolve_discussion.svg deleted file mode 100644 index 845562e9320..00000000000 --- a/app/views/shared/icons/_icon_resolve_discussion.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/views/shared/icons/_icon_status_success_solid.svg b/app/views/shared/icons/_icon_status_success_solid.svg deleted file mode 100644 index 0aac6d933e1..00000000000 --- a/app/views/shared/icons/_icon_status_success_solid.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/config/feature_flags/development/extract_mr_diff_commit_deletions.yml b/config/feature_flags/development/extract_mr_diff_commit_deletions.yml deleted file mode 100644 index 86fcb5ffc71..00000000000 --- a/config/feature_flags/development/extract_mr_diff_commit_deletions.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: extract_mr_diff_commit_deletions -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/75963 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/347073 -milestone: '14.6' -type: development -group: group::code review -default_enabled: false diff --git a/config/feature_flags/development/diff_searching_usage_data.yml b/config/feature_flags/development/usage_data_diff_searches.yml similarity index 79% rename from config/feature_flags/development/diff_searching_usage_data.yml rename to config/feature_flags/development/usage_data_diff_searches.yml index 27bd2c4959e..1fa0d2b934c 100644 --- a/config/feature_flags/development/diff_searching_usage_data.yml +++ b/config/feature_flags/development/usage_data_diff_searches.yml @@ -1,5 +1,5 @@ --- -name: diff_searching_usage_data +name: usage_data_diff_searches introduced_by_url: rollout_issue_url: milestone: '14.2' diff --git a/config/initializers/postgres_partitioning.rb b/config/initializers/postgres_partitioning.rb index 074dbac64a5..4de6e706f16 100644 --- a/config/initializers/postgres_partitioning.rb +++ b/config/initializers/postgres_partitioning.rb @@ -15,10 +15,12 @@ if Gitlab.ee? else Gitlab::Database::Partitioning.register_tables([ { + limit_connection_names: %i[main], table_name: 'incident_management_pending_alert_escalations', partitioned_column: :process_at, strategy: :monthly }, { + limit_connection_names: %i[main], table_name: 'incident_management_pending_issue_escalations', partitioned_column: :process_at, strategy: :monthly } @@ -31,6 +33,7 @@ unless Gitlab.jh? # This should be synchronized with the following model: # https://jihulab.com/gitlab-cn/gitlab/-/blob/main-jh/jh/app/models/phone/verification_code.rb { + limit_connection_names: %i[main], table_name: 'verification_codes', partitioned_column: :created_at, strategy: :monthly } diff --git a/config/initializers/rubyzip.rb b/config/initializers/rubyzip.rb new file mode 100644 index 00000000000..622abc2e9d8 --- /dev/null +++ b/config/initializers/rubyzip.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true +# +# Zip64 is needed to support archives with more than 65535 entries. +Zip.write_zip64_support = true diff --git a/doc/administration/package_information/supported_os.md b/doc/administration/package_information/supported_os.md index 78f496a3998..71ebc4d3647 100644 --- a/doc/administration/package_information/supported_os.md +++ b/doc/administration/package_information/supported_os.md @@ -23,7 +23,6 @@ The following lists the currently supported OSs and their possible EOL dates. | Debian 9 | GitLab CE / GitLab EE 9.3.0 | amd64 | 2022 | | | Debian 10 | GitLab CE / GitLab EE 12.2.0 | amd64, arm64 | 2024 | | | Debian 11 | GitLab CE / GitLab EE 14.6.0 | amd64, arm64 | 2026 | | -| OpenSUSE 15.2 | GitLab CE / GitLab EE 13.11.0 | x86_64, aarch64 | Dec 2021 | | | OpenSUSE 15.3 | GitLab CE / GitLab EE 14.5.0 | x86_64, aarch64 | Nov 2022 | | | SLES 12 | GitLab EE 9.0.0 | x86_64 | Oct 2027 | | | Ubuntu 18.04 | GitLab CE / GitLab EE 10.7.0 | amd64 | April 2023 | | @@ -81,8 +80,9 @@ release for them can be found below: | Raspbian Stretch | [June 2020](https://downloads.raspberrypi.org/raspbian/images/raspbian-2019-04-09/) | [GitLab CE](https://packages.gitlab.com/app/gitlab/raspberry-pi2/search?q=gitlab-ce_13.2&dist=raspbian%2Fstretch) 13.3 | | Debian Jessie | [June 2020](https://www.debian.org/News/2020/20200709) | [GitLab CE](https://packages.gitlab.com/app/gitlab/gitlab-ce/search?q=gitlab-ce_13.2&dist=debian%2Fjessie) / [GitLab EE](https://packages.gitlab.com/app/gitlab/gitlab-ee/search?q=gitlab-ee_13.2&dist=debian%2Fjessie) 13.3 | | CentOS 6 | [November 2020](https://wiki.centos.org/About/Product) | [GitLab CE](https://packages.gitlab.com/app/gitlab/gitlab-ce/search?q=13.6&filter=all&filter=all&dist=el%2F6) / [GitLab EE](https://packages.gitlab.com/app/gitlab/gitlab-ee/search?q=13.6&filter=all&filter=all&dist=el%2F6) 13.6 | -| OpenSUSE 15.1 | [November 2020](https://en.opensuse.org/Lifetime#Discontinued_distributions) | [GitLab CE](https://packages.gitlab.com/app/gitlab/gitlab-ce/search?q=gitlab-ce-13.12&dist=opensuse%2F15.1) / [GitLab EE](https://packages.gitlab.com/app/gitlab/gitlab-ee/search?q=gitlab-ee-13.12&dist=opensuse%2F15.2) 13.12 | +| OpenSUSE 15.1 | [November 2020](https://en.opensuse.org/Lifetime#Discontinued_distributions) | [GitLab CE](https://packages.gitlab.com/app/gitlab/gitlab-ce/search?q=gitlab-ce-13.12&dist=opensuse%2F15.1) / [GitLab EE](https://packages.gitlab.com/app/gitlab/gitlab-ee/search?q=gitlab-ee-13.12&dist=opensuse%2F15.1) 13.12 | | Ubuntu 16.04 | [April 2021](https://ubuntu.com/info/release-end-of-life) | [GitLab CE](https://packages.gitlab.com/app/gitlab/gitlab-ce/search?q=gitlab-ce_13.12&dist=ubuntu%2Fxenial) / [GitLab EE](https://packages.gitlab.com/app/gitlab/gitlab-ee/search?q=gitlab-ee_13.12&dist=ubuntu%2Fxenial) 13.12 | +| OpenSUSE 15.2 | [December 2021](https://en.opensuse.org/Lifetime#Discontinued_distributions) | [GitLab CE](https://packages.gitlab.com/app/gitlab/gitlab-ce/search?q=gitlab-ce-14.7&dist=opensuse%2F15.2) / [GitLab EE](https://packages.gitlab.com/app/gitlab/gitlab-ee/search?q=gitlab-ee-14.7&dist=opensuse%2F15.2) 14.7 | NOTE: An exception to this deprecation policy is when we are unable to provide diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index ee048b47e95..75ec02f344f 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -5145,6 +5145,27 @@ Input type: `WorkItemCreateInput` | `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | `workItem` | [`WorkItem`](#workitem) | Created work item. | +### `Mutation.workItemDelete` + +Deletes a work item. Available only when feature flag `work_items` is enabled. The feature is experimental and is subject to change without notice. + +Input type: `WorkItemDeleteInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| `project` | [`Project`](#project) | Project the deleted work item belonged to. | + ### `Mutation.workItemUpdate` Updates a work item by Global ID. Available only when feature flag `work_items` is enabled. The feature is experimental and is subject to change without notice. diff --git a/doc/development/service_ping/metrics_dictionary.md b/doc/development/service_ping/metrics_dictionary.md index f7f5a201aa3..c70dac1381d 100644 --- a/doc/development/service_ping/metrics_dictionary.md +++ b/doc/development/service_ping/metrics_dictionary.md @@ -6,8 +6,8 @@ info: To determine the technical writer assigned to the Stage/Group associated w # Metrics Dictionary Guide -[Service Ping](index.md) metrics are defined in the -[Metrics Dictionary](https://metrics.gitlab.com/). +[Service Ping](index.md) metrics are defined in individual YAML files definitions from which the +[Metrics Dictionary](https://metrics.gitlab.com/) is built. This guide describes the dictionary and how it's implemented. ## Metrics Definition and validation @@ -194,7 +194,7 @@ tier: - ultimate ``` -## Create a new metric definition +### Create a new metric definition The GitLab codebase provides a dedicated [generator](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/generators/gitlab/usage_metric_definition_generator.rb) to create new metric definitions. @@ -229,7 +229,7 @@ bundle exec rails generate gitlab:usage_metric_definition counts.issues --ee --d create ee/config/metrics/counts_7d/issues.yml ``` -## Metrics added dynamic to Service Ping payload +### Metrics added dynamic to Service Ping payload The [Redis HLL metrics](implement.md#known-events-are-added-automatically-in-service-data-payload) are added automatically to Service Ping payload. @@ -250,3 +250,13 @@ bundle exec rails generate gitlab:usage_metric_definition:redis_hll issues users create config/metrics/counts_7d/i_closed_weekly.yml create config/metrics/counts_28d/i_closed_monthly.yml ``` + +## Metrics Dictionary + +[Metrics Dictionary is a separate application](https://gitlab.com/gitlab-org/growth/product-intelligence/metric-dictionary). + +All metrics available in Service Ping are in the [Metrics Dictionary](https://metrics.gitlab.com/). + +### Copy query to clipboard + +To check if a metric has data in Sisense, use the copy query to clipboard feature. This copies a query that's ready to use in Sisense. The query gets the last five service ping data for GitLab.com for a given metric. For information about how to check if a Service Ping metric has data in Sisense, see this [demo](https://www.youtube.com/watch?v=n4o65ivta48). diff --git a/doc/subscriptions/gitlab_com/index.md b/doc/subscriptions/gitlab_com/index.md index 42ea07df166..c94d90f61b8 100644 --- a/doc/subscriptions/gitlab_com/index.md +++ b/doc/subscriptions/gitlab_com/index.md @@ -256,7 +256,7 @@ expiration date without a gap in available service. An invoice is generated for the renewal and available for viewing or download on the [View invoices](https://customers.gitlab.com/receipts) page. -#### Enable automatic renewal +#### Enable or disable automatic renewal To view or change automatic subscription renewal (at the same tier as the previous period), log in to the [Customers Portal](https://customers.gitlab.com/customers/sign_in), and: @@ -292,7 +292,7 @@ for more information. ### Purchase additional CI/CD minutes You can [purchase additional minutes](../../ci/pipelines/cicd_minutes.md#purchase-additional-cicd-minutes) -for your personal or group namespace. +for your personal or group namespace. CI/CD minutes are a **one-time purchase**, so they do not renew. ## Add-on subscription for additional Storage and Transfer @@ -309,7 +309,11 @@ locked. Projects can only be unlocked by purchasing more storage subscription un ### Purchase more storage and transfer -You can purchase storage for your personal or group namespace. +You can purchase a storage subscription for your personal or group namespace. + +NOTE: +Storage subscriptions **[renew automatically](#automatic-renewal) each year**. +You can [cancel the subscription](#enable-or-disable-automatic-renewal) to disable the automatic renewal. #### For your personal namespace diff --git a/doc/topics/release_your_application.md b/doc/topics/release_your_application.md index 59dd87032a4..64decba33ad 100644 --- a/doc/topics/release_your_application.md +++ b/doc/topics/release_your_application.md @@ -30,8 +30,8 @@ to Kubernetes clusters using the [GitLab Agent](../user/clusters/agent/install/i #### GitOps deployments **(PREMIUM)** -With the [GitLab Agent](../user/clusters/agent/install/index.md), you can perform pull-based -deployments using Kubernetes manifests. This provides a scalable, secure, and cloud-native +With the [GitLab Agent](../user/clusters/agent/install/index.md), you can perform [pull-based +deployments of Kubernetes manifests](../user/clusters/agent/repository.md#synchronize-manifest-projects). This provides a scalable, secure, and cloud-native approach to manage Kubernetes deployments. #### Deploy to Kubernetes with the CI/CD Tunnel diff --git a/doc/user/clusters/agent/index.md b/doc/user/clusters/agent/index.md index 06084186590..f85ca5aac8d 100644 --- a/doc/user/clusters/agent/index.md +++ b/doc/user/clusters/agent/index.md @@ -82,7 +82,7 @@ For more details, refer to our [architecture documentation](https://gitlab.com/g ## Install the Agent in your cluster -See how to [install the Agent in your cluster](install/index.md). +To connect your cluster to GitLab, [install the Agent on your cluster](install/index.md). ## GitOps deployments **(PREMIUM)** diff --git a/doc/user/project/merge_requests/approvals/rules.md b/doc/user/project/merge_requests/approvals/rules.md index 0379345ef4f..d94bcccd855 100644 --- a/doc/user/project/merge_requests/approvals/rules.md +++ b/doc/user/project/merge_requests/approvals/rules.md @@ -229,3 +229,16 @@ approval rule for certain branches: ![Scoped to protected branch](img/scoped_to_protected_branch_v13_10.png) 1. To enable this configuration, read [Code Owner's approvals for protected branches](../../protected_branches.md#require-code-owner-approval-on-a-protected-branch). + +## Troubleshooting + +### Approval rule name can't be blank + +As a workaround for this validation error, you can delete the approval rule through +the API. + +1. [GET a project-level rule](../../../../api/merge_request_approvals.md#get-a-single-project-level-rule). +1. [DELETE the rule](../../../../api/merge_request_approvals.md#delete-project-level-rule). + +For more information about this validation error, read +[issue 285129](https://gitlab.com/gitlab-org/gitlab/-/issues/285129). diff --git a/lib/gitlab/database/each_database.rb b/lib/gitlab/database/each_database.rb index 7c9e65e6691..c3eea0515d4 100644 --- a/lib/gitlab/database/each_database.rb +++ b/lib/gitlab/database/each_database.rb @@ -14,18 +14,40 @@ module Gitlab end end - def each_model_connection(models) + def each_model_connection(models, &blk) models.each do |model| - connection_name = model.connection.pool.db_config.name - - with_shared_connection(model.connection, connection_name) do - yield model, connection_name + # If model is shared, iterate all available base connections + # Example: `LooseForeignKeys::DeletedRecord` + if model < ::Gitlab::Database::SharedModel + with_shared_model_connections(model, &blk) + else + with_model_connection(model, &blk) end end end private + def with_shared_model_connections(shared_model, &blk) + Gitlab::Database.database_base_models.each_pair do |connection_name, connection_model| + if shared_model.limit_connection_names + next unless shared_model.limit_connection_names.include?(connection_name.to_sym) + end + + with_shared_connection(connection_model.connection, connection_name) do + yield shared_model, connection_name + end + end + end + + def with_model_connection(model, &blk) + connection_name = model.connection.pool.db_config.name + + with_shared_connection(model.connection, connection_name) do + yield model, connection_name + end + end + def with_shared_connection(connection, connection_name) Gitlab::Database::SharedModel.using_connection(connection) do Gitlab::AppLogger.debug(message: 'Switched database connection', connection_name: connection_name) diff --git a/lib/gitlab/database/partitioning.rb b/lib/gitlab/database/partitioning.rb index 1343354715a..c7d8bdf30bc 100644 --- a/lib/gitlab/database/partitioning.rb +++ b/lib/gitlab/database/partitioning.rb @@ -3,19 +3,8 @@ module Gitlab module Database module Partitioning - class TableWithoutModel - include PartitionedTable::ClassMethods - - attr_reader :table_name - - def initialize(table_name:, partitioned_column:, strategy:) - @table_name = table_name - partitioned_by(partitioned_column, strategy: strategy) - end - - def connection - Gitlab::Database::SharedModel.connection - end + class TableWithoutModel < Gitlab::Database::SharedModel + include PartitionedTable end class << self @@ -77,7 +66,15 @@ module Gitlab def registered_for_sync registered_models + registered_tables.map do |table| - TableWithoutModel.new(**table) + table_without_model(**table) + end + end + + def table_without_model(table_name:, partitioned_column:, strategy:, limit_connection_names: nil) + Class.new(TableWithoutModel).tap do |klass| + klass.table_name = table_name + klass.partitioned_by(partitioned_column, strategy: strategy) + klass.limit_connection_names = limit_connection_names end end end diff --git a/lib/gitlab/database/partitioning/partition_manager.rb b/lib/gitlab/database/partitioning/partition_manager.rb index ba6fa0cf278..ab414f91169 100644 --- a/lib/gitlab/database/partitioning/partition_manager.rb +++ b/lib/gitlab/database/partitioning/partition_manager.rb @@ -12,10 +12,15 @@ module Gitlab def initialize(model) @model = model + @connection_name = model.connection.pool.db_config.name end def sync_partitions - Gitlab::AppLogger.info(message: "Checking state of dynamic postgres partitions", table_name: model.table_name) + Gitlab::AppLogger.info( + message: "Checking state of dynamic postgres partitions", + table_name: model.table_name, + connection_name: @connection_name + ) # Double-checking before getting the lease: # The prevailing situation is no missing partitions and no extra partitions @@ -29,10 +34,13 @@ module Gitlab detach(partitions_to_detach) unless partitions_to_detach.empty? end rescue StandardError => e - Gitlab::AppLogger.error(message: "Failed to create / detach partition(s)", - table_name: model.table_name, - exception_class: e.class, - exception_message: e.message) + Gitlab::AppLogger.error( + message: "Failed to create / detach partition(s)", + table_name: model.table_name, + exception_class: e.class, + exception_message: e.message, + connection_name: @connection_name + ) end private @@ -98,9 +106,12 @@ module Gitlab Postgresql::DetachedPartition.create!(table_name: partition.partition_name, drop_after: RETAIN_DETACHED_PARTITIONS_FOR.from_now) - Gitlab::AppLogger.info(message: "Detached Partition", - partition_name: partition.partition_name, - table_name: partition.table) + Gitlab::AppLogger.info( + message: "Detached Partition", + partition_name: partition.partition_name, + table_name: partition.table, + connection_name: @connection_name + ) end def assert_partition_detachable!(partition) diff --git a/lib/gitlab/database/shared_model.rb b/lib/gitlab/database/shared_model.rb index 17d7886e8c8..563fab692ef 100644 --- a/lib/gitlab/database/shared_model.rb +++ b/lib/gitlab/database/shared_model.rb @@ -6,6 +6,10 @@ module Gitlab class SharedModel < ActiveRecord::Base self.abstract_class = true + # if shared model is used, this allows to limit connections + # on which this model is being shared + class_attribute :limit_connection_names, default: nil + class << self def using_connection(connection) previous_connection = self.overriding_connection diff --git a/lib/gitlab/popen.rb b/lib/gitlab/popen.rb index 7fa00d0c68c..586b271c4d0 100644 --- a/lib/gitlab/popen.rb +++ b/lib/gitlab/popen.rb @@ -10,10 +10,19 @@ module Gitlab Result = Struct.new(:cmd, :stdout, :stderr, :status, :duration) # Returns [stdout + stderr, status] + # status is either the exit code or the signal that killed the process def popen(cmd, path = nil, vars = {}, &block) result = popen_with_detail(cmd, path, vars, &block) - ["#{result.stdout}#{result.stderr}", result.status&.exitstatus] + # Process#waitpid returns Process::Status, which holds a 16-bit value. + # The higher-order 8 bits hold the exit() code (`exitstatus`). + # The lower-order bits holds whether the process was terminated. + # If the process didn't exit normally, `exitstatus` will be `nil`, + # but we still want a non-zero code, even if the value is + # platform-dependent. + status = result.status&.exitstatus || result.status.to_i + + ["#{result.stdout}#{result.stderr}", status] end # Returns Result diff --git a/lib/gitlab/usage_data_counters/known_events/code_review_events.yml b/lib/gitlab/usage_data_counters/known_events/code_review_events.yml index d4bc060abf9..9668b727099 100644 --- a/lib/gitlab/usage_data_counters/known_events/code_review_events.yml +++ b/lib/gitlab/usage_data_counters/known_events/code_review_events.yml @@ -236,7 +236,7 @@ redis_slot: code_review category: code_review aggregation: weekly - feature_flag: diff_searching_usage_data + feature_flag: usage_data_diff_searches - name: i_code_review_total_suggestions_applied redis_slot: code_review category: code_review diff --git a/lib/learn_gitlab/project.rb b/lib/learn_gitlab/project.rb index 599f9940e53..64f91dcf1a8 100644 --- a/lib/learn_gitlab/project.rb +++ b/lib/learn_gitlab/project.rb @@ -3,6 +3,7 @@ module LearnGitlab class Project PROJECT_NAME = 'Learn GitLab' + PROJECT_NAME_ULTIMATE_TRIAL = 'Learn GitLab - Ultimate trial' BOARD_NAME = 'GitLab onboarding' LABEL_NAME = 'Novice' @@ -15,7 +16,7 @@ module LearnGitlab end def project - @project ||= current_user.projects.find_by_name(PROJECT_NAME) + @project ||= current_user.projects.find_by_name([PROJECT_NAME, PROJECT_NAME_ULTIMATE_TRIAL]) end def board diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 2286eba1b62..85934f61eef 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -28213,6 +28213,9 @@ msgstr "" msgid "ProjectSettings|Merge requests approved for merge are queued, and pipelines validate the combined results of the source and target branches before merge. %{link_start}What are merge trains?%{link_end}" msgstr "" +msgid "ProjectSettings|Merge requests can't be merged if the latest pipeline did not succeed or is still running." +msgstr "" + msgid "ProjectSettings|Merge suggestions" msgstr "" @@ -28339,9 +28342,6 @@ msgstr "" msgid "ProjectSettings|This setting will be applied to all projects unless overridden by an admin." msgstr "" -msgid "ProjectSettings|To enable this feature, configure pipelines. %{link_start}How to configure merge request pipelines?%{link_end}" -msgstr "" - msgid "ProjectSettings|Transfer project" msgstr "" diff --git a/package.json b/package.json index 93c6450a99e..401d7cdf605 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "@gitlab/at.js": "1.5.7", "@gitlab/favicon-overlay": "2.0.0", "@gitlab/svgs": "2.2.0", - "@gitlab/ui": "34.0.0", + "@gitlab/ui": "35.0.0", "@gitlab/visual-review-tools": "1.6.1", "@rails/actioncable": "6.1.4-1", "@rails/ujs": "6.1.4-1", diff --git a/qa/qa/page/main/login.rb b/qa/qa/page/main/login.rb index f004107d7bd..a5bd37be287 100644 --- a/qa/qa/page/main/login.rb +++ b/qa/qa/page/main/login.rb @@ -157,6 +157,7 @@ module QA end def redirect_to_login_page(address) + Menu.perform(&:sign_out_if_signed_in) desired_host = URI(Runtime::Scenario.send("#{address}_address")).host Runtime::Browser.visit(address, Page::Main::Login) if desired_host != current_host end diff --git a/qa/qa/page/merge_request/show.rb b/qa/qa/page/merge_request/show.rb index f8d063ac6bd..be54081c925 100644 --- a/qa/qa/page/merge_request/show.rb +++ b/qa/qa/page/merge_request/show.rb @@ -83,10 +83,18 @@ module QA element :merge_immediately_menu_item end + view 'app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue' do + element :head_mismatch_content + end + view 'app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue' do element :squash_checkbox end + view 'app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue' do + element :mr_widget_content + end + view 'app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue' do element :apply_suggestion_dropdown element :commit_message_field @@ -269,13 +277,29 @@ module QA has_element?(:merge_button, disabled: false) end - # Waits up 60 seconds and raises an error if unable to merge - def wait_until_ready_to_merge - has_element?(:merge_button) + # Waits up 60 seconds and raises an error if unable to merge. + # + # If a state is encountered in which a user would typically refresh the page, this will refresh the page and + # then check again if it's ready to merge. For example, it will refresh if a new change was pushed and the page + # needs to be refreshed to show the change. + # + # @param [Boolean] transient_test true if the current test is a transient test (default: false) + def wait_until_ready_to_merge(transient_test: false) + wait_until do + has_element?(:merge_button) - # The merge button is enabled via JS - wait_until(reload: false) do - !find_element(:merge_button).disabled? + break true unless find_element(:merge_button).disabled? + + # If the widget shows "Merge blocked: new changes were just added" we can refresh the page and check again + next false if has_element?(:head_mismatch_content) + + # Stop waiting if we're in a transient test. By this point we're in an unexpected state and should let the + # test fail so we can investigate. If we're not in a transient test we keep trying until we reach timeout. + next true unless transient_test + + QA::Runtime::Logger.debug("MR widget text: #{mr_widget_text}") + + false end end @@ -385,6 +409,10 @@ module QA def cancel_auto_merge! click_element(:cancel_auto_merge_button) end + + def mr_widget_text + find_element(:mr_widget_content).text + end end end end diff --git a/qa/qa/specs/features/api/1_manage/migration/gitlab_migration_mr_spec.rb b/qa/qa/specs/features/api/1_manage/migration/gitlab_migration_mr_spec.rb index a33c87cea50..332133b6a4e 100644 --- a/qa/qa/specs/features/api/1_manage/migration/gitlab_migration_mr_spec.rb +++ b/qa/qa/specs/features/api/1_manage/migration/gitlab_migration_mr_spec.rb @@ -29,7 +29,8 @@ module QA let!(:source_comment) { source_mr.add_comment('This is a test comment!') } let(:imported_mrs) { imported_project.merge_requests } - let(:imported_mr_comments) { imported_mr.comments } + let(:imported_mr_comments) { imported_mr.comments.map { |note| note.except(:id, :noteable_id) } } + let(:source_mr_comments) { source_mr.comments.map { |note| note.except(:id, :noteable_id) } } let(:imported_mr) do Resource::MergeRequest.init do |mr| @@ -53,8 +54,7 @@ module QA aggregate_failures do expect(imported_mr).to eq(source_mr.reload!) - expect(imported_mr_comments.count).to eq(1) - expect(imported_mr_comments.first.except(:id, :noteable_id)).to eq(source_comment.except(:id, :noteable_id)) + expect(imported_mr_comments).to eq(source_mr_comments) end end end diff --git a/qa/qa/specs/features/browser_ui/3_create/merge_request/merge_when_pipeline_succeeds_spec.rb b/qa/qa/specs/features/browser_ui/3_create/merge_request/merge_when_pipeline_succeeds_spec.rb index 9a771919c11..ec8a9367f68 100644 --- a/qa/qa/specs/features/browser_ui/3_create/merge_request/merge_when_pipeline_succeeds_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/merge_request/merge_when_pipeline_succeeds_spec.rb @@ -20,25 +20,6 @@ module QA end before do - Resource::Repository::Commit.fabricate_via_api! do |commit| - commit.project = project - commit.commit_message = 'Add .gitlab-ci.yml' - commit.add_files( - [ - { - file_path: '.gitlab-ci.yml', - content: <<~EOF - test: - tags: ["runner-for-#{project.name}"] - script: sleep 20 - only: - - merge_requests - EOF - } - ] - ) - end - Flow::Login.sign_in end @@ -48,8 +29,10 @@ module QA end it 'merges after pipeline succeeds' do + transient_test = repeat > 1 + repeat.times do |i| - QA::Runtime::Logger.info("Transient bug test - Trial #{i}") if repeat > 1 + QA::Runtime::Logger.info("Transient bug test - Trial #{i}") if transient_test branch_name = "mr-test-#{SecureRandom.hex(6)}-#{i}" @@ -68,19 +51,59 @@ module QA merge_request.no_preparation = true end + # Load the page so that the browser is as prepared as possible to display the pipeline in progress when we + # start it. merge_request.visit! - Page::MergeRequest::Show.perform do |mr| - mr.merge_when_pipeline_succeeds! + # Push a new pipeline config file + Resource::Repository::Commit.fabricate_via_api! do |commit| + commit.project = project + commit.commit_message = 'Add .gitlab-ci.yml' + commit.branch = branch_name + commit.add_files( + [ + { + file_path: '.gitlab-ci.yml', + content: <<~EOF + test: + tags: ["runner-for-#{project.name}"] + script: sleep 20 + only: + - merge_requests + EOF + } + ] + ) + end - Support::Waiter.wait_until(sleep_interval: 5) do + Page::MergeRequest::Show.perform do |mr| + refresh + + # Part of the challenge with this test is that the MR widget has many components that could be displayed + # and many errors states that those components could encounter. Most of the time few of those + # possible components will be relevant, so it would be inefficient for this test to check for each of + # them. Instead, we fail on anything but the expected state. + # + # The following method allows us to handle and ignore states (as we find them) that users could safely ignore. + mr.wait_until_ready_to_merge(transient_test: transient_test) + + mr.retry_until(reload: true, message: 'Wait until ready to click MWPS') do merge_request = merge_request.reload! - merge_request.state == 'merged' + + # Don't try to click MWPS if the MR is merged or the pipeline is complete + break if merge_request.state == 'merged' || project.pipelines.last[:status] == 'success' + + # Try to click MWPS if this is a transient test, or if the MWPS button is visible, + # otherwise reload the page and retry + next false unless transient_test || mr.has_element?(:merge_button, text: 'Merge when pipeline succeeds') + + # No need to keep retrying if we can click MWPS + break mr.merge_when_pipeline_succeeds! end aggregate_failures do - expect(merge_request.merge_when_pipeline_succeeds).to be_truthy expect(mr.merged?).to be_truthy, "Expected content 'The changes were merged' but it did not appear." + expect(merge_request.reload!.merge_when_pipeline_succeeds).to be_truthy end end end diff --git a/scripts/rspec_bisect_flaky b/scripts/rspec_bisect_flaky index efeb9bcb5a0..2ef6dedb4c2 100755 --- a/scripts/rspec_bisect_flaky +++ b/scripts/rspec_bisect_flaky @@ -10,20 +10,20 @@ if [ $# -eq 0 ]; then exit fi -files=( $@ ) +files=( "$@" ) len=${#files[@]} target=${files[$len-1]} # Trap interrupts and exit instead of continuing the loop trap "echo Exited!; exit 2;" SIGINT SIGTERM -# Show which set of specs are running -set -x +# Show which set of specs are running and exit immediately if they fail. +set -xe # Do the speedy case first, run each spec with our failing spec for file in "${files[@]}"; do - bin/rspec $file $target + bin/rspec "$file" "$target" done # Do a full bisect given we did not find candidates with speedy cases -bin/rspec --bisect=verbose $@ +bin/rspec --bisect=verbose "$@" diff --git a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap index 10437c48f88..82114077455 100644 --- a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap +++ b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap @@ -17,7 +17,6 @@ exports[`AddContextCommitsModal renders modal with 2 tabs 1`] = ` diff --git a/spec/frontend/vue_shared/components/namespace_select/namespace_select_spec.js b/spec/frontend/vue_shared/components/namespace_select/namespace_select_spec.js index cebb6279178..9e137bb2b36 100644 --- a/spec/frontend/vue_shared/components/namespace_select/namespace_select_spec.js +++ b/spec/frontend/vue_shared/components/namespace_select/namespace_select_spec.js @@ -1,5 +1,5 @@ import { nextTick } from 'vue'; -import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import NamespaceSelect, { i18n, @@ -7,6 +7,10 @@ import NamespaceSelect, { } from '~/vue_shared/components/namespace_select/namespace_select.vue'; import { user, group, namespaces } from './mock_data'; +const FLAT_NAMESPACES = [...group, ...user]; +const EMPTY_NAMESPACE_TITLE = 'Empty namespace TEST'; +const EMPTY_NAMESPACE_ITEM = { id: EMPTY_NAMESPACE_ID, humanName: EMPTY_NAMESPACE_TITLE }; + describe('Namespace Select', () => { let wrapper; @@ -16,67 +20,97 @@ describe('Namespace Select', () => { data: namespaces, ...props, }, + stubs: { + // We have to "full" mount GlDropdown so that slot children will render + GlDropdown, + }, }); const wrappersText = (arr) => arr.wrappers.map((w) => w.text()); - const flatNamespaces = () => [...group, ...user]; const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownAttributes = (attr) => findDropdown().attributes(attr); - const selectedDropdownItemText = () => findDropdownAttributes('text'); + const findDropdownText = () => findDropdown().props('text'); const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownItemsTexts = () => findDropdownItems().wrappers.map((x) => x.text()); const findSectionHeaders = () => wrapper.findAllComponents(GlDropdownSectionHeader); - - beforeEach(() => { - wrapper = createComponent(); - }); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + const search = (term) => findSearchBox().vm.$emit('input', term); afterEach(() => { wrapper.destroy(); }); - it('renders the dropdown', () => { - expect(findDropdown().exists()).toBe(true); + describe('default', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('renders the dropdown', () => { + expect(findDropdown().exists()).toBe(true); + }); + + it('renders each dropdown item', () => { + expect(findDropdownItemsTexts()).toEqual(FLAT_NAMESPACES.map((x) => x.humanName)); + }); + + it('renders default dropdown text', () => { + expect(findDropdownText()).toBe(i18n.DEFAULT_TEXT); + }); + + it('splits group and user namespaces', () => { + const headers = findSectionHeaders(); + expect(wrappersText(headers)).toEqual([i18n.GROUPS, i18n.USERS]); + }); + + it('does not render wrapper as full width', () => { + expect(findDropdown().attributes('block')).toBeUndefined(); + }); }); - it('can override the default text', () => { + it('with defaultText, it overrides dropdown text', () => { const textOverride = 'Select an option'; + wrapper = createComponent({ defaultText: textOverride }); - expect(selectedDropdownItemText()).toBe(textOverride); + + expect(findDropdownText()).toBe(textOverride); }); - it('renders each dropdown item', () => { - const items = findDropdownItems().wrappers; - expect(items).toHaveLength(flatNamespaces().length); - }); - - it('renders the human name for each item', () => { - const dropdownItems = wrappersText(findDropdownItems()); - const flatNames = flatNamespaces().map(({ humanName }) => humanName); - expect(dropdownItems).toEqual(flatNames); - }); - - it('sets the initial dropdown text', () => { - expect(selectedDropdownItemText()).toBe(i18n.DEFAULT_TEXT); - }); - - it('splits group and user namespaces', () => { - const headers = findSectionHeaders(); - expect(headers).toHaveLength(2); - expect(wrappersText(headers)).toEqual([i18n.GROUPS, i18n.USERS]); - }); - - it('can hide the group / user headers', () => { + it('with includeHeaders=false, hides group/user headers', () => { wrapper = createComponent({ includeHeaders: false }); + expect(findSectionHeaders()).toHaveLength(0); }); - it('sets the dropdown to full width', () => { - expect(findDropdownAttributes('block')).toBeUndefined(); - + it('with fullWidth=true, sets the dropdown to full width', () => { wrapper = createComponent({ fullWidth: true }); - expect(findDropdownAttributes('block')).not.toBeUndefined(); - expect(findDropdownAttributes('block')).toBe('true'); + expect(findDropdown().attributes('block')).toBe('true'); + }); + + describe('with search', () => { + it.each` + term | includeEmptyNamespace | expectedItems + ${''} | ${false} | ${[...namespaces.group, ...namespaces.user]} + ${'sub'} | ${false} | ${[namespaces.group[1]]} + ${'User'} | ${false} | ${[...namespaces.user]} + ${'User'} | ${true} | ${[...namespaces.user]} + ${'namespace'} | ${true} | ${[EMPTY_NAMESPACE_ITEM, ...namespaces.user]} + `( + 'with term=$term and includeEmptyNamespace=$includeEmptyNamespace, should show $expectedItems.length', + async ({ term, includeEmptyNamespace, expectedItems }) => { + wrapper = createComponent({ + includeEmptyNamespace, + emptyNamespaceTitle: EMPTY_NAMESPACE_TITLE, + }); + + search(term); + + await nextTick(); + + const expected = expectedItems.map((x) => x.humanName); + + expect(findDropdownItemsTexts()).toEqual(expected); + }, + ); }); describe('with a selected namespace', () => { @@ -84,11 +118,13 @@ describe('Namespace Select', () => { const selectedItem = group[selectedGroupIndex]; beforeEach(() => { + wrapper = createComponent(); + findDropdownItems().at(selectedGroupIndex).vm.$emit('click'); }); it('sets the dropdown text', () => { - expect(selectedDropdownItemText()).toBe(selectedItem.humanName); + expect(findDropdownText()).toBe(selectedItem.humanName); }); it('emits the `select` event when a namespace is selected', () => { @@ -98,27 +134,35 @@ describe('Namespace Select', () => { }); describe('with an empty namespace option', () => { - const emptyNamespaceTitle = 'No namespace selected'; - - beforeEach(async () => { + beforeEach(() => { wrapper = createComponent({ includeEmptyNamespace: true, - emptyNamespaceTitle, + emptyNamespaceTitle: EMPTY_NAMESPACE_TITLE, }); - await nextTick(); }); it('includes the empty namespace', () => { const first = findDropdownItems().at(0); - expect(first.text()).toBe(emptyNamespaceTitle); + + expect(first.text()).toBe(EMPTY_NAMESPACE_TITLE); }); it('emits the `select` event when a namespace is selected', () => { findDropdownItems().at(0).vm.$emit('click'); - expect(wrapper.emitted('select')).toEqual([ - [{ id: EMPTY_NAMESPACE_ID, humanName: emptyNamespaceTitle }], - ]); + expect(wrapper.emitted('select')).toEqual([[EMPTY_NAMESPACE_ITEM]]); + }); + + it.each` + desc | term | shouldShow + ${'should hide empty option'} | ${'group'} | ${false} + ${'should show empty option'} | ${'Empty'} | ${true} + `('when search for $term, $desc', async ({ term, shouldShow }) => { + search(term); + + await nextTick(); + + expect(findDropdownItemsTexts().includes(EMPTY_NAMESPACE_TITLE)).toBe(shouldShow); }); }); }); diff --git a/spec/lib/gitlab/database/each_database_spec.rb b/spec/lib/gitlab/database/each_database_spec.rb index 9327fc4ff78..d526b3bc1ac 100644 --- a/spec/lib/gitlab/database/each_database_spec.rb +++ b/spec/lib/gitlab/database/each_database_spec.rb @@ -4,45 +4,97 @@ require 'spec_helper' RSpec.describe Gitlab::Database::EachDatabase do describe '.each_database_connection' do - let(:expected_connections) do - Gitlab::Database.database_base_models.map { |name, model| [model.connection, name] } + before do + allow(Gitlab::Database).to receive(:database_base_models) + .and_return({ main: ActiveRecord::Base, ci: Ci::ApplicationRecord }.with_indifferent_access) end - it 'yields each connection after connecting SharedModel' do - expected_connections.each do |connection, _| - expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(connection).and_yield - end + it 'yields each connection after connecting SharedModel', :add_ci_connection do + expect(Gitlab::Database::SharedModel).to receive(:using_connection) + .with(ActiveRecord::Base.connection).ordered.and_yield - yielded_connections = [] + expect(Gitlab::Database::SharedModel).to receive(:using_connection) + .with(Ci::ApplicationRecord.connection).ordered.and_yield - described_class.each_database_connection do |connection, name| - yielded_connections << [connection, name] - end - - expect(yielded_connections).to match_array(expected_connections) + expect { |b| described_class.each_database_connection(&b) } + .to yield_successive_args( + [ActiveRecord::Base.connection, 'main'], + [Ci::ApplicationRecord.connection, 'ci'] + ) end end describe '.each_model_connection' do - let(:model1) { double(connection: double, table_name: 'table1') } - let(:model2) { double(connection: double, table_name: 'table2') } + context 'when the model inherits from SharedModel', :add_ci_connection do + let(:model1) { Class.new(Gitlab::Database::SharedModel) } + let(:model2) { Class.new(Gitlab::Database::SharedModel) } - before do - allow(model1.connection).to receive_message_chain('pool.db_config.name').and_return('name1') - allow(model2.connection).to receive_message_chain('pool.db_config.name').and_return('name2') - end - - it 'yields each model after connecting SharedModel' do - expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(model1.connection).and_yield - expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(model2.connection).and_yield - - yielded_models = [] - - described_class.each_model_connection([model1, model2]) do |model, name| - yielded_models << [model, name] + before do + allow(Gitlab::Database).to receive(:database_base_models) + .and_return({ main: ActiveRecord::Base, ci: Ci::ApplicationRecord }.with_indifferent_access) end - expect(yielded_models).to match_array([[model1, 'name1'], [model2, 'name2']]) + it 'yields each model with SharedModel connected to each database connection' do + expect_yielded_models([model1, model2], [ + { model: model1, connection: ActiveRecord::Base.connection, name: 'main' }, + { model: model1, connection: Ci::ApplicationRecord.connection, name: 'ci' }, + { model: model2, connection: ActiveRecord::Base.connection, name: 'main' }, + { model: model2, connection: Ci::ApplicationRecord.connection, name: 'ci' } + ]) + end + + context 'when the model limits connection names' do + before do + model1.limit_connection_names = %i[main] + model2.limit_connection_names = %i[ci] + end + + it 'only yields the model with SharedModel connected to the limited connections' do + expect_yielded_models([model1, model2], [ + { model: model1, connection: ActiveRecord::Base.connection, name: 'main' }, + { model: model2, connection: Ci::ApplicationRecord.connection, name: 'ci' } + ]) + end + end + end + + context 'when the model does not inherit from SharedModel' do + let(:main_model) { Class.new(ActiveRecord::Base) } + let(:ci_model) { Class.new(Ci::ApplicationRecord) } + + let(:main_connection) { double(:connection) } + let(:ci_connection) { double(:connection) } + + before do + allow(main_model).to receive(:connection).and_return(main_connection) + allow(ci_model).to receive(:connection).and_return(ci_connection) + + allow(main_connection).to receive_message_chain('pool.db_config.name').and_return('main') + allow(ci_connection).to receive_message_chain('pool.db_config.name').and_return('ci') + end + + it 'yields each model after connecting SharedModel' do + expect_yielded_models([main_model, ci_model], [ + { model: main_model, connection: main_connection, name: 'main' }, + { model: ci_model, connection: ci_connection, name: 'ci' } + ]) + end + end + + def expect_yielded_models(models_to_iterate, expected_values) + times_yielded = 0 + + described_class.each_model_connection(models_to_iterate) do |model, name| + expected = expected_values[times_yielded] + + expect(model).to be(expected[:model]) + expect(model.connection).to be(expected[:connection]) + expect(name).to eq(expected[:name]) + + times_yielded += 1 + end + + expect(times_yielded).to eq(expected_values.size) end end end diff --git a/spec/lib/gitlab/popen_spec.rb b/spec/lib/gitlab/popen_spec.rb index 891482a5f17..8211806a809 100644 --- a/spec/lib/gitlab/popen_spec.rb +++ b/spec/lib/gitlab/popen_spec.rb @@ -40,6 +40,17 @@ RSpec.describe Gitlab::Popen do it { expect(@output).to include('No such file or directory') } end + context 'non-zero status with a kill' do + let(:cmd) { [Gem.ruby, "-e", "thr = Thread.new { sleep 5 }; Process.kill(9, Process.pid); thr.join"] } + + before do + @output, @status = @klass.new.popen(cmd) + end + + it { expect(@status).to eq(9) } + it { expect(@output).to be_empty } + end + context 'unsafe string command' do it 'raises an error when it gets called with a string argument' do expect { @klass.new.popen('ls', path) }.to raise_error(RuntimeError) diff --git a/spec/lib/learn_gitlab/project_spec.rb b/spec/lib/learn_gitlab/project_spec.rb index 523703761bf..5d649740c65 100644 --- a/spec/lib/learn_gitlab/project_spec.rb +++ b/spec/lib/learn_gitlab/project_spec.rb @@ -5,6 +5,7 @@ require 'spec_helper' RSpec.describe LearnGitlab::Project do let_it_be(:current_user) { create(:user) } let_it_be(:learn_gitlab_project) { create(:project, name: LearnGitlab::Project::PROJECT_NAME) } + let_it_be(:learn_gitlab_ultimate_trial_project) { create(:project, name: LearnGitlab::Project::PROJECT_NAME_ULTIMATE_TRIAL) } let_it_be(:learn_gitlab_board) { create(:board, project: learn_gitlab_project, name: LearnGitlab::Project::BOARD_NAME) } let_it_be(:learn_gitlab_label) { create(:label, project: learn_gitlab_project, name: LearnGitlab::Project::LABEL_NAME) } @@ -45,6 +46,12 @@ RSpec.describe LearnGitlab::Project do subject { described_class.new(current_user).project } it { is_expected.to eq learn_gitlab_project } + + context 'when it is created during trial signup' do + let_it_be(:learn_gitlab_project) { create(:project, name: LearnGitlab::Project::PROJECT_NAME_ULTIMATE_TRIAL) } + + it { is_expected.to eq learn_gitlab_project } + end end describe '.board' do diff --git a/spec/requests/api/graphql/mutations/work_items/delete_spec.rb b/spec/requests/api/graphql/mutations/work_items/delete_spec.rb new file mode 100644 index 00000000000..14c8b757a57 --- /dev/null +++ b/spec/requests/api/graphql/mutations/work_items/delete_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Delete a work item' do + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } } + + let(:current_user) { developer } + let(:mutation) { graphql_mutation(:workItemDelete, { 'id' => work_item.to_global_id.to_s }) } + let(:mutation_response) { graphql_mutation_response(:work_item_delete) } + + context 'when the user is not allowed to delete a work item' do + let(:work_item) { create(:work_item, project: project) } + + it_behaves_like 'a mutation that returns a top-level access error' + end + + context 'when user has permissions to delete a work item' do + let_it_be(:authored_work_item, refind: true) { create(:work_item, project: project, author: developer, assignees: [developer]) } + + let(:work_item) { authored_work_item } + + it 'deletes the work item' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + end.to change(WorkItem, :count).by(-1) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['project']).to include('id' => work_item.project.to_global_id.to_s) + end + + context 'when the work_items feature flag is disabled' do + before do + stub_feature_flags(work_items: false) + end + + it 'does not delete the work item' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + end.to not_change(WorkItem, :count) + + expect(mutation_response['errors']).to contain_exactly('`work_items` feature flag disabled for this project') + end + end + end +end diff --git a/spec/services/pages/zip_directory_service_spec.rb b/spec/services/pages/zip_directory_service_spec.rb index 9cce90c6c0d..00fe75dbbfd 100644 --- a/spec/services/pages/zip_directory_service_spec.rb +++ b/spec/services/pages/zip_directory_service_spec.rb @@ -27,6 +27,10 @@ RSpec.describe Pages::ZipDirectoryService do let(:archive) { result[:archive_path] } let(:entries_count) { result[:entries_count] } + it 'returns true if ZIP64 is enabled' do + expect(::Zip.write_zip64_support).to be true + end + shared_examples 'handles invalid public directory' do it 'returns success' do expect(status).to eq(:success) @@ -35,7 +39,7 @@ RSpec.describe Pages::ZipDirectoryService do end end - context "when work direcotry doesn't exist" do + context "when work directory doesn't exist" do let(:service_directory) { "/tmp/not/existing/dir" } include_examples 'handles invalid public directory' diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb index ab984dcaf84..a6aa76c7e6c 100644 --- a/spec/services/projects/destroy_service_spec.rb +++ b/spec/services/projects/destroy_service_spec.rb @@ -97,9 +97,13 @@ RSpec.describe Projects::DestroyService, :aggregate_failures do end end - shared_examples_for "deleting a project with merge requests" do + context "deleting a project with merge requests" do let!(:merge_request) { create(:merge_request, source_project: project) } + before do + allow(project).to receive(:destroy!).and_return(true) + end + it "deletes merge request and related records" do merge_request_diffs = merge_request.merge_request_diffs expect(merge_request_diffs.size).to eq(1) @@ -119,25 +123,6 @@ RSpec.describe Projects::DestroyService, :aggregate_failures do destroy_project(project, user, {}) end - context "extract_mr_diff_commit_deletions feature flag" do - context "with flag enabled" do - before do - stub_feature_flags(extract_mr_diff_commit_deletions: true) - allow(project).to receive(:destroy!).and_return(true) - end - - it_behaves_like "deleting a project with merge requests" - end - - context "with flag disabled" do - before do - stub_feature_flags(extract_mr_diff_commit_deletions: false) - end - - it_behaves_like "deleting a project with merge requests" - end - end - context 'with running pipelines' do let!(:pipelines) { create_list(:ci_pipeline, 3, :running, project: project) } let(:destroy_pipeline_service) { double('DestroyPipelineService', execute: nil) } diff --git a/spec/services/work_items/delete_service_spec.rb b/spec/services/work_items/delete_service_spec.rb new file mode 100644 index 00000000000..6cca5018852 --- /dev/null +++ b/spec/services/work_items/delete_service_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WorkItems::DeleteService do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:guest) { create(:user) } + let_it_be(:work_item, refind: true) { create(:work_item, project: project, author: guest) } + + let(:user) { guest } + + before_all do + project.add_guest(guest) + # note necessary to test note removal as part of work item deletion + create(:note, project: project, noteable: work_item) + end + + describe '#execute' do + subject(:result) { described_class.new(project: project, current_user: user).execute(work_item) } + + context 'when user can delete the work item' do + it { is_expected.to be_success } + + # currently we don't expect destroy to fail. Mocking here for coverage and keeping + # the service's return type consistent + context 'when there are errors preventing to delete the work item' do + before do + allow(work_item).to receive(:destroy).and_return(false) + work_item.errors.add(:title) + end + + it { is_expected.to be_error } + + it 'returns error messages' do + expect(result.errors).to contain_exactly('Title is invalid') + end + end + end + + context 'when user cannot delete the work item' do + let(:user) { create(:user) } + + it { is_expected.to be_error } + + it 'returns error messages' do + expect(result.errors).to contain_exactly('User not authorized to delete work item') + end + end + end +end diff --git a/yarn.lock b/yarn.lock index ac1494be01c..b94a11ef175 100644 --- a/yarn.lock +++ b/yarn.lock @@ -962,10 +962,10 @@ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-2.2.0.tgz#95cf58d6ae634d535145159f08f5cff6241d4013" integrity sha512-mCwR3KfNPsxRoojtTjMIZwdd4FFlBh5DlR9AeodP+7+k8rILdWGYxTZbJMPNXoPbZx16R94nG8c5bR7toD4QBw== -"@gitlab/ui@34.0.0": - version "34.0.0" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-34.0.0.tgz#0fe9574df2c38aeb63add94e4549ed4e65975ef8" - integrity sha512-BFh3x+GCqWAoWhNJhJUunW3eHQLQkBOTBwZFJWSS+1+9ZtetqU3t0/OoqYjJuyTsqdra7A/e6BZsU0j7CnbY+Q== +"@gitlab/ui@35.0.0": + version "35.0.0" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-35.0.0.tgz#9fb89babddc337830f1245044fe7946b266395b4" + integrity sha512-iGGsLFgy/BOnmym2VBT+ByiP7mY/DtJPDSoYjd7QtJbOF17A+MyvOwBFGTUXAJxDtWTYSkMZkEuwZVA3VOEwyQ== dependencies: "@babel/standalone" "^7.0.0" bootstrap-vue "2.20.1"