Commit e3b2cac1 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 62cac026 8188d09f
......@@ -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');
}
......
......@@ -43,9 +43,6 @@ export default {
};
},
computed: {
selectedNamespaceId() {
return this.selectedId;
},
disableSubmitButton() {
return this.isPaidGroup || !this.selectedId;
},
......
......@@ -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;
......
......@@ -578,7 +578,7 @@ export default {
:endpoint="mr.accessibilityReportPath"
/>
<div class="mr-widget-section">
<div class="mr-widget-section" data-qa-selector="mr_widget_content">
<component :is="componentName" :mr="mr" :service="service" />
<ready-to-merge
v-if="isRestructuredMrWidgetEnabled && mr.commitsCount"
......
......@@ -16,8 +16,13 @@ export const i18n = {
USERS: __('Users'),
};
const filterByName = (data, searchTerm = '') =>
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: {
......
......@@ -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]
......
# 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
......@@ -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
......
......@@ -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
# 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
......@@ -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 = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.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: '</a>'.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'
......
---
name: diff_searching_usage_data
name: usage_data_diff_searches
introduced_by_url:
rollout_issue_url:
milestone: '14.2'
......
# frozen_string_literal: true
#
# Zip64 is needed to support archives with more than 65535 entries.
Zip.write_zip64_support = true
......@@ -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 | <https://wiki.debian.org/LTS> |
| Debian 10 | GitLab CE / GitLab EE 12.2.0 | amd64, arm64 | 2024 | <https://wiki.debian.org/LTS> |
| Debian 11 | GitLab CE / GitLab EE 14.6.0 | amd64, arm64 | 2026 | <https://wiki.debian.org/LTS> |
| OpenSUSE 15.2 | GitLab CE / GitLab EE 13.11.0 | x86_64, aarch64 | Dec 2021 | <https://en.opensuse.org/Lifetime> |
| OpenSUSE 15.3 | GitLab CE / GitLab EE 14.5.0 | x86_64, aarch64 | Nov 2022 | <https://en.opensuse.org/Lifetime> |
| SLES 12 | GitLab EE 9.0.0 | x86_64 | Oct 2027 | <https://www.suse.com/lifecycle/> |
| Ubuntu 18.04 | GitLab CE / GitLab EE 10.7.0 | amd64 | April 2023 | <https://wiki.ubuntu.com/Releases> |
......@@ -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
......
......@@ -5145,6 +5145,27 @@ Input type: `WorkItemCreateInput`
| <a id="mutationworkitemcreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationworkitemcreateworkitem"></a>`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 |
| ---- | ---- | ----------- |
| <a id="mutationworkitemdeleteclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationworkitemdeleteid"></a>`id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationworkitemdeleteclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationworkitemdeleteerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationworkitemdeleteproject"></a>`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.
......@@ -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
......
......@@ -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)**
......
......@@ -55,6 +55,29 @@ module Geo
self.verification_failed!
end
override :track_checksum_attempt!
def track_checksum_attempt!(&block)
# If this resource will never become checksummed on the primary (because
# e.g. it is a remote stored file), then as a bandaid, mark it as
# verification succeeded. This will stop the cycle of:
# Sync succeeded => Verification failed => Sync failed => Sync succeeded
#
# A better fix is proposed in
# https://gitlab.com/gitlab-org/gitlab/-/issues/299819
if will_never_be_checksummed_on_the_primary?
# To ensure we avoid transition errors
self.verification_started unless self.verification_started?
# A checksum value is required by a state machine validation rule, so
# set it to zeroes
self.verification_checksum = '0000000000000000000000000000000000000000'
self.verification_succeeded!
return
end
super
end
private
override :track_checksum_result!
......@@ -90,10 +113,8 @@ module Geo
self.verification_pending!
end
# For example, remote stored files are filtered from available_verifiables
# because we don't support verification of remote stored files.
def will_never_be_checksummed_on_the_primary?
!replicator.model_record.in_available_verifiables?
replicator.will_never_be_checksummed_on_the_primary?
end
override :before_verification_failed
......
......@@ -262,6 +262,12 @@ module Geo
Gitlab::Geo.secondary? ? registry : model_record
end
# For example, remote stored files are filtered from available_verifiables
# because we don't support verification of remote stored files.
def will_never_be_checksummed_on_the_primary?
!model_record.in_available_verifiables?
end
# @abstract
# @return [String] a checksum representing the data
def calculate_checksum
......
......@@ -565,6 +565,24 @@ RSpec.shared_examples 'a verifiable replicator' do
end
end
describe '#will_never_be_checksummed_on_the_primary?' do
context 'when the model record is not in available_verifiables' do
it 'returns true' do
allow(model_record).to receive(:in_available_verifiables?).and_return(false)
expect(replicator.will_never_be_checksummed_on_the_primary?).to be_truthy
end
end
context 'when the model record is in available_verifiables' do
it 'returns false' do
allow(model_record).to receive(:in_available_verifiables?).and_return(true)
expect(replicator.will_never_be_checksummed_on_the_primary?).to be_falsey
end
end
end
context 'integration tests' do
before do
model_record.save!
......
......@@ -204,6 +204,8 @@ RSpec.shared_examples 'a Geo verifiable registry' do
describe '#track_checksum_attempt!', :aggregate_failures do
context 'when verification was not yet started' do
it 'starts verification' do
allow(subject).to receive(:will_never_be_checksummed_on_the_primary?).and_return(false)
expect do
subject.track_checksum_attempt! do
'a_checksum_value'
......@@ -211,45 +213,85 @@ RSpec.shared_examples 'a Geo verifiable registry' do
end.to change { subject.verification_started_at }.from(nil)
end
context 'comparison with primary checksum' do
let(:replicator) { double('replicator') }
let(:calculated_checksum) { 'abc123' }
context 'when the model record will never be checksummed on the primary' do
before do
allow(subject).to receive(:replicator).and_return(replicator)
allow(replicator).to receive(:matches_checksum?).with(calculated_checksum).and_return(matches_checksum)
allow(registry).to receive(:will_never_be_checksummed_on_the_primary?).and_return(true)
end
context 'when the calculated checksum matches the primary checksum' do
let(:matches_checksum) { true }
context 'when the registry is already verification_succeeded' do
let(:registry) { create(registry_class_factory, :started, :verification_succeeded) }
it 'transitions to verification_succeeded and updates the checksum' do
it 'leaves verification as succeeded' do
expect do
subject.track_checksum_attempt! do
calculated_checksum
registry.track_checksum_attempt! do
''
end
end.to change { subject.verification_succeeded? }.from(false).to(true)
end.not_to change { registry.verification_succeeded? }
expect(subject.verification_checksum).to eq(calculated_checksum)
expect(registry.verification_checksum).to eq('0000000000000000000000000000000000000000')
end
end
context 'when the calculated checksum does not match the primary checksum' do
let(:matches_checksum) { false }
it 'transitions to verification_failed and updates mismatch fields' do
allow(replicator).to receive(:primary_checksum).and_return(calculated_checksum)
context 'when the registry is verification_pending' do
let(:registry) { create(registry_class_factory, :started) }
it 'changes verification to succeeded' do
expect do
subject.track_checksum_attempt! do
calculated_checksum
registry.track_checksum_attempt! do
''
end
end.to change { subject.verification_failed? }.from(false).to(true)
end.to change { registry.verification_succeeded? }.from(false).to(true)
expect(subject.verification_checksum).to eq(calculated_checksum)
expect(subject.verification_checksum_mismatched).to eq(calculated_checksum)
expect(subject.checksum_mismatch).to eq(true)
expect(subject.verification_failure).to match('Checksum does not match the primary checksum')
expect(registry.verification_checksum).to eq('0000000000000000000000000000000000000000')
end
end
end
context 'when the primary site is expected to checksum the model record' do
before do
allow(replicator).to receive(:will_never_be_checksummed_on_the_primary?).and_return(false)
end
context 'comparison with primary checksum' do
let(:replicator) { double('replicator') }
let(:calculated_checksum) { 'abc123' }
before do
allow(subject).to receive(:replicator).and_return(replicator)
allow(replicator).to receive(:matches_checksum?).with(calculated_checksum).and_return(matches_checksum)
end
context 'when the calculated checksum matches the primary checksum' do
let(:matches_checksum) { true }
it 'transitions to verification_succeeded and updates the checksum' do
expect do
subject.track_checksum_attempt! do
calculated_checksum
end
end.to change { subject.verification_succeeded? }.from(false).to(true)
expect(subject.verification_checksum).to eq(calculated_checksum)
end
end
context 'when the calculated checksum does not match the primary checksum' do
let(:matches_checksum) { false }
it 'transitions to verification_failed and updates mismatch fields' do
allow(replicator).to receive(:primary_checksum).and_return(calculated_checksum)
expect do
subject.track_checksum_attempt! do
calculated_checksum
end
end.to change { subject.verification_failed? }.from(false).to(true)
expect(subject.verification_checksum).to eq(calculated_checksum)
expect(subject.verification_checksum_mismatched).to eq(calculated_checksum)
expect(subject.checksum_mismatch).to eq(true)
expect(subject.verification_failure).to match('Checksum does not match the primary checksum')
end
end
end
end
......@@ -257,6 +299,8 @@ RSpec.shared_examples 'a Geo verifiable registry' do
context 'when verification was started' do
it 'does not update verification_started_at' do
allow(subject).to receive(:will_never_be_checksummed_on_the_primary?).and_return(false)
subject.verification_started!
expected = subject.verification_started_at
......@@ -269,6 +313,8 @@ RSpec.shared_examples 'a Geo verifiable registry' do
end
it 'yields to the checksum calculation' do
allow(subject).to receive(:will_never_be_checksummed_on_the_primary?).and_return(false)
expect do |probe|
subject.track_checksum_attempt!(&probe)
end.to yield_with_no_args
......@@ -276,6 +322,8 @@ RSpec.shared_examples 'a Geo verifiable registry' do
context 'when an error occurs while yielding' do
it 'sets verification_failed' do
allow(subject).to receive(:will_never_be_checksummed_on_the_primary?).and_return(false)
subject.track_checksum_attempt! do
raise 'an error'
end
......@@ -285,28 +333,6 @@ RSpec.shared_examples 'a Geo verifiable registry' do
end
end
describe '#will_never_be_checksummed_on_the_primary?' do
context 'when the model record is not in available_verifiables' do
it 'returns true' do
model_record = double('model_record', in_available_verifiables?: false)
replicator = double('replicator', model_record: model_record)
allow(subject).to receive(:replicator).and_return(replicator)
expect(subject.will_never_be_checksummed_on_the_primary?).to be_truthy
end
end
context 'when the model record is in available_verifiables' do
it 'returns false' do
model_record = double('model_record', in_available_verifiables?: true)
replicator = double('replicator', model_record: model_record)
allow(subject).to receive(:replicator).and_return(replicator)
expect(subject.will_never_be_checksummed_on_the_primary?).to be_falsey
end
end
end
def verification_state_value(key)
described_class.verification_state_value(key)
end
......
......@@ -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
......
......@@ -28222,6 +28222,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 ""
......@@ -28348,9 +28351,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 ""
......
......@@ -15,10 +15,6 @@ module QA
base.class_eval do
prepend Page::Component::LicenseManagement
view 'app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue' do
element :head_mismatch_content
end
view 'ee/app/views/projects/merge_requests/_code_owner_approval_rules.html.haml' do
element :approver_content
element :approver_list_content
......@@ -225,12 +221,7 @@ module QA
end
def merge_via_merge_train
# Revisit after merge page re-architect is done https://gitlab.com/gitlab-org/gitlab/-/issues/300042
# To remove page refresh logic if possible
wait_until_ready_to_merge
wait_until { !find_element(:merge_button).has_text?("when pipeline succeeds") }
click_element(:merge_button)
try_to_merge!
finished_loading?
end
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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!
# 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
Page::MergeRequest::Show.perform do |mr|
mr.merge_when_pipeline_succeeds!
refresh
Support::Waiter.wait_until(sleep_interval: 5) do
# 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
......
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('can override the default text', () => {
const textOverride = 'Select an option';
wrapper = createComponent({ defaultText: textOverride });
expect(selectedDropdownItemText()).toBe(textOverride);
});
it('renders the dropdown', () => {
expect(findDropdown().exists()).toBe(true);
});
it('renders each dropdown item', () => {
const items = findDropdownItems().wrappers;
expect(items).toHaveLength(flatNamespaces().length);
});
it('renders each dropdown item', () => {
expect(findDropdownItemsTexts()).toEqual(FLAT_NAMESPACES.map((x) => x.humanName));
});
it('renders the human name for each item', () => {
const dropdownItems = wrappersText(findDropdownItems());
const flatNames = flatNamespaces().map(({ humanName }) => humanName);
expect(dropdownItems).toEqual(flatNames);
});
it('renders default dropdown text', () => {
expect(findDropdownText()).toBe(i18n.DEFAULT_TEXT);
});
it('sets the initial dropdown text', () => {
expect(selectedDropdownItemText()).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('splits group and user namespaces', () => {
const headers = findSectionHeaders();
expect(headers).toHaveLength(2);
expect(wrappersText(headers)).toEqual([i18n.GROUPS, i18n.USERS]);
it('with defaultText, it overrides dropdown text', () => {
const textOverride = 'Select an option';
wrapper = createComponent({ defaultText: textOverride });
expect(findDropdownText()).toBe(textOverride);
});
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);
});
});
});
# 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
......@@ -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'
......
# 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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment