Commit bbbc98bf authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents a82e997f b631a172
0b8fa6eb7dafa519aba4e95cd6e3a16c3c435436
bdbed7a8a30246ee83d325615bb43213bf7a1d69
......@@ -24,7 +24,7 @@ export default () => {
} = domEl.dataset;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
});
return new Vue({
......
<script>
import { GlIcon, GlIntersectionObserver } from '@gitlab/ui';
import { GlIcon, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui';
import Visibility from 'visibilityjs';
import createFlash from '~/flash';
import Poll from '~/lib/utils/poll';
......@@ -32,6 +32,9 @@ export default {
formComponent,
PinnedLinks,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
endpoint: {
required: true,
......@@ -183,6 +186,11 @@ export default {
required: false,
default: true,
},
isHidden: {
type: Boolean,
required: false,
default: false,
},
},
data() {
const store = new Store({
......@@ -508,6 +516,15 @@ export default {
<span v-if="isConfidential" data-testid="confidential" class="issuable-warning-icon">
<gl-icon name="eye-slash" :aria-label="__('Confidential')" />
</span>
<span
v-if="isHidden"
v-gl-tooltip
:title="__('This issue is hidden because its author has been banned')"
data-testid="hidden"
class="issuable-warning-icon"
>
<gl-icon name="spam" />
</span>
<p
class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0"
:title="state.titleText"
......
......@@ -135,9 +135,9 @@ export default {
</gl-form-checkbox>
<gl-form-checkbox
v-if="canBeLockedToProject"
v-model="model.locked"
data-testid="runner-field-locked"
:disabled="!canBeLockedToProject"
>
{{ __('Lock to current projects') }}
<template #help>
......
......@@ -28,6 +28,7 @@ import {
CONFIRM,
WARNING,
MT_MERGE_STRATEGY,
PIPELINE_FAILED_STATE,
} from '../../constants';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
......@@ -39,7 +40,6 @@ import CommitsHeader from './commits_header.vue';
import SquashBeforeMerge from './squash_before_merge.vue';
const PIPELINE_RUNNING_STATE = 'running';
const PIPELINE_FAILED_STATE = 'failed';
const PIPELINE_PENDING_STATE = 'pending';
const PIPELINE_SUCCESS_STATE = 'success';
......@@ -105,6 +105,10 @@ export default {
import(
'ee_component/vue_merge_request_widget/components/merge_immediately_confirmation_dialog.vue'
),
MergeTrainFailedPipelineConfirmationDialog: () =>
import(
'ee_component/vue_merge_request_widget/components/merge_train_failed_pipeline_confirmation_dialog.vue'
),
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -125,6 +129,7 @@ export default {
squashBeforeMerge: this.mr.squashIsSelected,
isSquashReadOnly: this.mr.squashIsReadonly,
squashCommitMessage: this.mr.squashCommitMessage,
isPipelineFailedModalVisible: false,
};
},
computed: {
......@@ -327,7 +332,12 @@ export default {
: this.mr.commitMessageWithDescription;
this.commitMessage = includeDescription ? commitMessageWithDescription : commitMessage;
},
handleMergeButtonClick(useAutoMerge, mergeImmediately = false) {
handleMergeButtonClick(useAutoMerge, mergeImmediately = false, confirmationClicked = false) {
if (this.showFailedPipelineModal && !confirmationClicked) {
this.isPipelineFailedModalVisible = true;
return;
}
if (mergeImmediately) {
this.isMergingImmediately = true;
}
......@@ -522,6 +532,11 @@ export default {
@mergeImmediately="onMergeImmediatelyConfirmation"
/>
</gl-dropdown>
<merge-train-failed-pipeline-confirmation-dialog
:visible="isPipelineFailedModalVisible"
@startMergeTrain="onStartMergeTrainConfirmation"
@cancel="isPipelineFailedModalVisible = false"
/>
</gl-button-group>
<div
v-if="shouldShowMergeControls"
......
......@@ -10,6 +10,8 @@ export const MWPS_MERGE_STRATEGY = 'merge_when_pipeline_succeeds';
export const MTWPS_MERGE_STRATEGY = 'add_to_merge_train_when_pipeline_succeeds';
export const MT_MERGE_STRATEGY = 'merge_train';
export const PIPELINE_FAILED_STATE = 'failed';
export const AUTO_MERGE_STRATEGIES = [MWPS_MERGE_STRATEGY, MTWPS_MERGE_STRATEGY, MT_MERGE_STRATEGY];
// SP - "Suggest Pipelines"
......
......@@ -38,5 +38,13 @@ export default {
pipelineId() {
return this.pipeline.id;
},
showFailedPipelineModal() {
return false;
},
},
methods: {
onStartMergeTrainConfirmation() {
return false;
},
},
};
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import IssuableHeaderWarnings from './issuable_header_warnings.vue';
export default function issuableHeaderWarnings(store) {
const el = document.getElementById('js-issuable-header-warnings');
if (!el) {
return false;
}
const { hidden } = el.dataset;
return new Vue({
el: document.getElementById('js-issuable-header-warnings'),
el,
store,
provide: { hidden: parseBoolean(hidden) },
render(createElement) {
return createElement(IssuableHeaderWarnings);
},
......
<script>
import { GlIcon } from '@gitlab/ui';
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import { __ } from '~/locale';
export default {
components: {
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: ['hidden'],
computed: {
...mapGetters(['getNoteableData']),
isLocked() {
......@@ -26,6 +31,12 @@ export default {
visible: this.isConfidential,
dataTestId: 'confidential',
},
{
iconName: 'spam',
visible: this.hidden,
dataTestId: 'hidden',
tooltip: __('This issue is hidden because its author has been banned'),
},
];
},
},
......@@ -35,8 +46,15 @@ export default {
<template>
<div class="gl-display-inline-block">
<template v-for="meta in warningIconsMeta">
<div v-if="meta.visible" :key="meta.iconName" class="issuable-warning-icon inline">
<gl-icon :name="meta.iconName" :data-testid="meta.dataTestId" class="icon" />
<div
v-if="meta.visible"
:key="meta.iconName"
v-gl-tooltip
:data-testid="meta.dataTestId"
:title="meta.tooltip || null"
class="issuable-warning-icon inline"
>
<gl-icon :name="meta.iconName" class="icon" />
</div>
</template>
</div>
......
......@@ -7,6 +7,7 @@
text-align: center;
margin-right: $issuable-warning-icon-margin;
line-height: $gl-line-height-24;
flex: 0 0 auto;
}
.limit-container-width {
......
......@@ -77,7 +77,12 @@ class InvitesController < ApplicationController
def track_invite_join_click
return unless member && initial_invite_email?
experiment(:invite_email_preview_text, actor: member).track(:join_clicked) if params[:experiment_name] == 'invite_email_preview_text'
if params[:experiment_name] == 'invite_email_preview_text'
experiment(:invite_email_preview_text, actor: member).track(:join_clicked)
elsif params[:experiment_name] == 'invite_email_from'
experiment(:invite_email_from, actor: member).track(:join_clicked)
end
Gitlab::Tracking.event(self.class.name, 'join_clicked', label: 'invite_email', property: member.id.to_s)
end
......
......@@ -201,6 +201,7 @@ class RegistrationsController < Devise::RegistrationsController
experiment_name = session.delete(:invite_email_experiment_name)
experiment(:invite_email_preview_text, actor: member).track(:accepted) if experiment_name == 'invite_email_preview_text'
experiment(:invite_email_from, actor: member).track(:accepted) if experiment_name == 'invite_email_from'
Gitlab::Tracking.event(self.class.name, 'accepted', label: 'invite_email', property: member.id.to_s)
end
......
......@@ -256,7 +256,8 @@ module IssuablesHelper
issueType: issuable.issue_type,
zoomMeetingUrl: ZoomMeeting.canonical_meeting_url(issuable),
sentryIssueIdentifier: SentryIssue.find_by(issue: issuable)&.sentry_issue_identifier, # rubocop:disable CodeReuse/ActiveRecord
iid: issuable.iid.to_s
iid: issuable.iid.to_s,
isHidden: issue_hidden?(issuable)
}
end
......
......@@ -60,8 +60,16 @@ module IssuesHelper
sprite_icon('eye-slash', css_class: 'gl-vertical-align-text-bottom') if issue.confidential?
end
def issue_hidden?(issue)
Feature.enabled?(:ban_user_feature_flag) && issue.hidden?
end
def hidden_issue_icon(issue)
sprite_icon('spam', css_class: 'gl-vertical-align-text-bottom') if issue.hidden?
return unless issue_hidden?(issue)
content_tag(:span, class: 'has-tooltip', title: _('This issue is hidden because its author has been banned')) do
sprite_icon('spam', css_class: 'gl-vertical-align-text-bottom')
end
end
def award_user_list(awards, current_user, limit: 10)
......
......@@ -24,7 +24,14 @@ module NotifyHelper
def invited_join_url(token, member)
additional_params = { invite_type: Emails::Members::INITIAL_INVITE }
if experiment(:invite_email_preview_text, actor: member).enabled?
# order important below to our scheduled testing of these
# `from` experiment will be after the `text` on, but we may not cleanup
# from the `text` one by the time we run the `from` experiment,
# therefore we want to support `text` being fully enabled
# but if `from` is also enabled, then we only care about `from`
if experiment(:invite_email_from, actor: member).enabled?
additional_params[:experiment_name] = 'invite_email_from'
elsif experiment(:invite_email_preview_text, actor: member).enabled?
additional_params[:experiment_name] = 'invite_email_preview_text'
end
......
......@@ -57,7 +57,7 @@ module Emails
Gitlab::Tracking.event(self.class.name, 'invite_email_sent', label: 'invite_email', property: member_id.to_s)
mail(to: member.invite_email, subject: invite_email_subject, **invite_email_headers) do |format|
mail(to: member.invite_email, subject: invite_email_subject, **invite_email_headers.merge(additional_invite_settings)) do |format|
format.html { render layout: 'unknown_user_mailer' }
format.text { render layout: 'unknown_user_mailer' }
end
......@@ -147,7 +147,17 @@ module Emails
def invite_email_subject
if member.created_by
subject(s_("MemberInviteEmail|%{member_name} invited you to join GitLab") % { member_name: member.created_by.name })
experiment(:invite_email_from, actor: member) do |experiment_instance|
experiment_instance.use do
subject(s_("MemberInviteEmail|%{member_name} invited you to join GitLab") % { member_name: member.created_by.name })
end
experiment_instance.candidate do
subject(s_("MemberInviteEmail|I've invited you to join me in GitLab"))
end
experiment_instance.run
end
else
subject(s_("MemberInviteEmail|Invitation to join the %{project_or_group} %{project_or_group_name}") % { project_or_group: member_source.human_name, project_or_group_name: member_source.model_name.singular })
end
......@@ -164,6 +174,21 @@ module Emails
end
end
def additional_invite_settings
return {} unless member.created_by
experiment(:invite_email_from, actor: member) do |experiment_instance|
experiment_instance.use { {} }
experiment_instance.candidate do
{
from: "#{member.created_by.name} <#{member.created_by.email}>"
}
end
experiment_instance.run
end
end
def member_exists?
Gitlab::AppLogger.info("Tried to send an email invitation for a deleted group. Member id: #{@member_id}") if member.blank?
member.present?
......
......@@ -69,6 +69,14 @@ class IssuePolicy < IssuablePolicy
rule { persisted & can?(:admin_issue) }.policy do
enable :set_issue_metadata
end
rule { can?(:set_issue_metadata) }.policy do
enable :set_confidentiality
end
rule { ~persisted & can?(:create_issue) }.policy do
enable :set_confidentiality
end
end
IssuePolicy.prepend_mod_with('IssuePolicy')
......@@ -51,9 +51,12 @@ class IssuableBaseService < ::BaseProjectService
params.delete(:canonical_issue_id)
params.delete(:project)
params.delete(:discussion_locked)
params.delete(:confidential)
end
# confidential attribute is a special type of metadata and needs to be allowed to be set
# by non-members on issues in public projects so that security issues can be reported as confidential.
params.delete(:confidential) unless can?(current_user, :set_confidentiality, issuable)
filter_assignees(issuable)
filter_milestone
filter_labels
......
......@@ -4,15 +4,7 @@
- page_title "##{@runner.id} (#{@runner.short_sha})"
- add_to_breadcrumbs _('Runners'), admin_runners_path
- if Feature.enabled?(:runner_detailed_view_vue_ui, current_user, default_enabled: :yaml)
#js-runner-details{ data: {runner_id: @runner.id} }
- else
%h2.page-title
= s_('Runners|Runner #%{runner_id}' % { runner_id: @runner.id })
= render 'shared/runners/runner_type_badge', runner: @runner
= render 'shared/runners/runner_type_alert', runner: @runner
.gl-mb-6
= render 'shared/runners/form', runner: @runner, runner_form_url: admin_runner_path(@runner), in_gitlab_com_admin_context: Gitlab.com?
#js-runner-details{ data: {runner_id: @runner.id} }
.row
.col-md-6
......
......@@ -12,9 +12,7 @@
- if issue.confidential?
%span.has-tooltip{ title: _('Confidential') }
= confidential_icon(issue)
- if Feature.enabled?(:ban_user_feature_flag) && issue.hidden?
%span.has-tooltip{ title: _('This issue is hidden because its author has been banned') }
= hidden_issue_icon(issue)
= hidden_issue_icon(issue)
= link_to issue.title, issue_path(issue)
= render_if_exists 'projects/issues/subepic_flag', issue: issue
- if issue.tasks?
......
- project = local_assigns.fetch(:project)
- issuable = local_assigns.fetch(:issuable)
- presenter = local_assigns.fetch(:presenter)
- return unless can?(current_user, :"set_#{issuable.to_ability_name}_metadata", issuable)
- has_due_date = issuable.has_attribute?(:due_date)
- form = local_assigns.fetch(:form)
- if issuable.respond_to?(:confidential)
- if issuable.respond_to?(:confidential) && can?(current_user, :set_confidentiality, issuable)
.form-group.row
.offset-sm-2.col-sm-10
.form-check
......@@ -15,39 +12,40 @@
= form.label :confidential, class: 'form-check-label' do
This issue is confidential and should only be visible to team members with at least Reporter access.
%hr
.row
%div{ class: (has_due_date ? "col-lg-6" : "col-12") }
.form-group.row.merge-request-assignee
= render "shared/issuable/form/metadata_issuable_assignee", issuable: issuable, form: form, has_due_date: has_due_date
- if issuable.allows_reviewers?
.form-group.row.merge-request-reviewer
= render "shared/issuable/form/metadata_issuable_reviewer", issuable: issuable, form: form, has_due_date: has_due_date, presenter: presenter
- if can?(current_user, :"set_#{issuable.to_ability_name}_metadata", issuable)
%hr
.row
%div{ class: (has_due_date ? "col-lg-6" : "col-12") }
.form-group.row.merge-request-assignee
= render "shared/issuable/form/metadata_issuable_assignee", issuable: issuable, form: form, has_due_date: has_due_date
= render_if_exists "shared/issuable/form/epic", issuable: issuable, form: form, project: project
- if issuable.supports_milestone?
.form-group.row.issue-milestone
= form.label :milestone_id, "Milestone", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}"
.col-sm-10{ class: ("col-md-8" if has_due_date) }
.issuable-form-select-holder
= render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, show_started: false, extra_class: "qa-issuable-milestone-dropdown js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone"
- if issuable.allows_reviewers?
.form-group.row.merge-request-reviewer
= render "shared/issuable/form/metadata_issuable_reviewer", issuable: issuable, form: form, has_due_date: has_due_date, presenter: presenter
.form-group.row
= form.label :label_ids, "Labels", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}"
= form.hidden_field :label_ids, multiple: true, value: ''
.col-sm-10{ class: "#{"col-md-8" if has_due_date}" }
.issuable-form-select-holder
= render "shared/issuable/label_dropdown", classes: ["js-issuable-form-dropdown"], selected: issuable.labels, data_options: { field_name: "#{issuable.class.model_name.param_key}[label_ids][]", show_any: false }, dropdown_title: "Select label"
= render_if_exists "shared/issuable/form/epic", issuable: issuable, form: form, project: project
= render_if_exists "shared/issuable/form/merge_request_blocks", issuable: issuable, form: form
- if issuable.supports_milestone?
.form-group.row.issue-milestone
= form.label :milestone_id, "Milestone", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}"
.col-sm-10{ class: ("col-md-8" if has_due_date) }
.issuable-form-select-holder
= render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, show_started: false, extra_class: "qa-issuable-milestone-dropdown js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone"
- if has_due_date
.col-lg-6
= render_if_exists "shared/issuable/form/weight", issuable: issuable, form: form
.form-group.row
= form.label :due_date, "Due date", class: "col-form-label col-md-2 col-lg-4"
.col-8
= form.label :label_ids, "Labels", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}"
= form.hidden_field :label_ids, multiple: true, value: ''
.col-sm-10{ class: "#{"col-md-8" if has_due_date}" }
.issuable-form-select-holder
= form.text_field :due_date, id: "issuable-due-date", class: "datepicker form-control", placeholder: "Select due date", autocomplete: 'off'
= render "shared/issuable/label_dropdown", classes: ["js-issuable-form-dropdown"], selected: issuable.labels, data_options: { field_name: "#{issuable.class.model_name.param_key}[label_ids][]", show_any: false }, dropdown_title: "Select label"
= render_if_exists "shared/issuable/form/merge_request_blocks", issuable: issuable, form: form
- if has_due_date
.col-lg-6
= render_if_exists "shared/issuable/form/weight", issuable: issuable, form: form
.form-group.row
= form.label :due_date, "Due date", class: "col-form-label col-md-2 col-lg-4"
.col-8
.issuable-form-select-holder
= form.text_field :due_date, id: "issuable-due-date", class: "datepicker form-control", placeholder: "Select due date", autocomplete: 'off'
......@@ -15,7 +15,7 @@
= _('Open')
.issuable-meta
#js-issuable-header-warnings
#js-issuable-header-warnings{ data: { hidden: issue_hidden?(issuable).to_s } }
= issuable_meta(issuable, @project)
%a.btn.gl-button.btn-default.btn-icon.float-right.gl-display-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" }
......
---
name: runner_detailed_view_vue_ui
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57256
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/325737
milestone: '13.11'
type: development
group: group::runner
default_enabled: true
---
name: set_full_path
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/66929
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/337002
milestone: '14.2'
type: development
group: group::gitaly
name: invite_email_from
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68376
rollout_issue_url: https://gitlab.com/gitlab-org/growth/team-tasks/-/issues/429
milestone: '14.3'
type: experiment
group: group::expansion
default_enabled: false
......@@ -7,7 +7,7 @@ import {
GlButton,
GlTooltip,
} from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { isEmpty, isNumber } from 'lodash';
import { mapState, mapActions } from 'vuex';
import ItemWeight from 'ee/boards/components/issue_card_weight.vue';
......@@ -75,6 +75,9 @@ export default {
hasAssignees() {
return this.item.assignees && this.item.assignees.length > 0;
},
hasWeight() {
return isNumber(this.item.weight);
},
stateText() {
return this.isOpen ? __('Opened') : __('Closed');
},
......@@ -260,7 +263,7 @@ export default {
/>
<item-weight
v-if="item.weight"
v-if="hasWeight"
:weight="item.weight"
class="item-weight gl-display-flex gl-align-items-center gl-mr-5!"
tag-name="span"
......
import {
modelToUpdateMutationVariables as cemodelToUpdateMutationVariables,
modelToUpdateMutationVariables as ceModelToUpdateMutationVariables,
runnerToModel as ceRunnerToModel,
} from '~/runner/runner_details/runner_update_form_utils';
......@@ -16,7 +16,7 @@ export const modelToUpdateMutationVariables = (model) => {
return {
input: {
...cemodelToUpdateMutationVariables(model).input,
...ceModelToUpdateMutationVariables(model).input,
privateProjectsMinutesCostFactor:
privateProjectsMinutesCostFactor !== '' ? privateProjectsMinutesCostFactor : null,
publicProjectsMinutesCostFactor:
......
<script>
import { GlModal, GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
name: 'MergeTrainFailedPipelineConfirmationDialog',
i18n: {
title: __('Start merge train'),
cancel: __('Cancel'),
info: __('The latest pipeline for this merge request has failed.'),
confirmation: __('Are you sure you want to attempt to merge?'),
},
components: {
GlModal,
GlButton,
},
props: {
visible: {
type: Boolean,
required: true,
},
},
methods: {
hide() {
this.$refs.modal.hide();
},
cancel() {
this.hide();
this.$emit('cancel');
},
focusCancelButton() {
this.$refs.cancelButton.$el.focus();
},
startMergeTrain() {
this.$emit('startMergeTrain');
this.hide();
},
},
};
</script>
<template>
<gl-modal
ref="modal"
modal-id="merge-train-failed-pipeline-confirmation-dialog"
size="sm"
:title="$options.i18n.title"
:visible="visible"
@shown="focusCancelButton"
@hide="$emit('cancel')"
>
<p>{{ $options.i18n.info }}</p>
<p>{{ $options.i18n.confirmation }}</p>
<template #modal-footer>
<gl-button ref="cancelButton" @click="cancel">{{ $options.i18n.cancel }}</gl-button>
<gl-button variant="danger" data-testid="start-merge-train" @click="startMergeTrain">
{{ $options.i18n.title }}
</gl-button>
</template>
</gl-modal>
</template>
import { isNumber, isString } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import { MTWPS_MERGE_STRATEGY, MT_MERGE_STRATEGY } from '~/vue_merge_request_widget/constants';
import {
MTWPS_MERGE_STRATEGY,
MT_MERGE_STRATEGY,
PIPELINE_FAILED_STATE,
} from '~/vue_merge_request_widget/constants';
import base from '~/vue_merge_request_widget/mixins/ready_to_merge';
export const MERGE_DISABLED_TEXT_UNAPPROVED = __(
......@@ -76,5 +80,16 @@ export default {
isMergeImmediatelyDangerous() {
return [MT_MERGE_STRATEGY, MTWPS_MERGE_STRATEGY].includes(this.preferredAutoMergeStrategy);
},
showFailedPipelineModal() {
const pipelineFailed = this.status === PIPELINE_FAILED_STATE || this.isPipelineFailed;
const mergeStrateyMergeTrain = this.preferredAutoMergeStrategy === MT_MERGE_STRATEGY;
return pipelineFailed && mergeStrateyMergeTrain;
},
},
methods: {
onStartMergeTrainConfirmation() {
this.handleMergeButtonClick(this.isAutoMergeAvailable, false, true);
},
},
};
......@@ -38,5 +38,6 @@ class EpicPolicy < BasePolicy
rule { can?(:admin_epic) }.policy do
enable :set_epic_metadata
enable :set_confidentiality
end
end
import { GlButton, GlLink, GlIcon } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import ItemWeight from 'ee/boards/components/issue_card_weight.vue';
......@@ -28,8 +29,7 @@ import {
mockEpicMeta3,
} from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
Vue.use(Vuex);
let mockItem;
......@@ -68,7 +68,6 @@ const createComponent = (parentItem = mockParentItem, item = mockItem) => {
});
return shallowMount(TreeItemBody, {
localVue,
store,
propsData: {
parentItem,
......@@ -337,7 +336,7 @@ describe('RelatedItemsTree', () => {
});
it('renders item link', () => {
const link = wrapper.find(GlLink);
const link = wrapper.findComponent(GlLink);
expect(link.attributes('href')).toBe(mockItem.webPath);
expect(link.text()).toBe(mockItem.title);
......@@ -350,13 +349,13 @@ describe('RelatedItemsTree', () => {
});
it('renders item milestone when it has milestone', () => {
const milestone = wrapper.find(ItemMilestone);
const milestone = wrapper.findComponent(ItemMilestone);
expect(milestone.isVisible()).toBe(true);
});
it('renders item due date when it has due date', () => {
const dueDate = wrapper.find(ItemDueDate);
const dueDate = wrapper.findComponent(ItemDueDate);
expect(dueDate.isVisible()).toBe(true);
});
......@@ -371,23 +370,39 @@ describe('RelatedItemsTree', () => {
await wrapper.vm.$nextTick();
expect(wrapper.find(ItemDueDate).props('closed')).toBe(true);
expect(wrapper.findComponent(ItemDueDate).props('closed')).toBe(true);
});
it('renders item weight when it has weight', () => {
const weight = wrapper.find(ItemWeight);
const weight = wrapper.findComponent(ItemWeight);
expect(weight.isVisible()).toBe(true);
});
it('renders item weight when it has weight of 0', async () => {
wrapper.setProps({
item: {
...mockItem,
weight: 0,
},
});
await wrapper.vm.$nextTick();
const weight = wrapper.findComponent(ItemWeight);
expect(weight.isVisible()).toBe(true);
expect(weight.props('weight')).toBe(0);
});
it('renders item assignees when it has assignees', () => {
const assignees = wrapper.find(ItemAssignees);
const assignees = wrapper.findComponent(ItemAssignees);
expect(assignees.isVisible()).toBe(true);
});
it('renders item remove button when `item.userPermissions.adminEpic` is true', () => {
const removeButton = wrapper.find(GlButton);
const removeButton = wrapper.findComponent(GlButton);
expect(removeButton.isVisible()).toBe(true);
expect(removeButton.attributes('title')).toBe('Remove');
......
import { mount } from '@vue/test-utils';
import RunnerUpdateCostFactorFields from 'ee/runner/components/runner_update_cost_factor_fields.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { runnerData } from 'jest/runner/mock_data';
const mockRunner = runnerData.data.runner;
describe('RunnerUpdateCostFactorFields', () => {
let wrapper;
const findPrivateProjectsCostFactor = () =>
wrapper.findByTestId('runner-field-private-projects-cost-factor');
const findPublicProjectsCostFactor = () =>
wrapper.findByTestId('runner-field-public-projects-cost-factor');
const triggerInput = (formGroup, value) => {
const input = formGroup.find('input');
input.element.value = value;
input.trigger('input');
};
const createComponent = () => {
wrapper = extendedWrapper(
mount(RunnerUpdateCostFactorFields, {
propsData: {
value: mockRunner,
},
}),
);
};
describe('when on dot_com', () => {
beforeEach(() => {
gon.dot_com = true;
createComponent();
});
it('shows cost factor fields', () => {
expect(findPrivateProjectsCostFactor().exists()).toBe(true);
expect(findPublicProjectsCostFactor().exists()).toBe(true);
});
it('handles input of private cost factor', async () => {
triggerInput(findPrivateProjectsCostFactor(), '3.50');
expect(wrapper.emitted('input').length).toBe(1);
expect(wrapper.emitted('input')[0]).toEqual([
{
...mockRunner,
privateProjectsMinutesCostFactor: 3.5,
},
]);
});
it('handles input of public cost factor', async () => {
triggerInput(findPublicProjectsCostFactor(), '2.50');
expect(wrapper.emitted('input').length).toBe(1);
expect(wrapper.emitted('input')[0]).toEqual([
{
...mockRunner,
publicProjectsMinutesCostFactor: 2.5,
},
]);
});
});
describe('when self-hosted', () => {
beforeEach(() => {
gon.dot_com = false;
createComponent();
});
it('does not show cost factor fields', () => {
expect(findPrivateProjectsCostFactor().exists()).toBe(false);
expect(findPublicProjectsCostFactor().exists()).toBe(false);
});
});
});
import { shallowMount } from '@vue/test-utils';
import MergeTrainFailedPipelineConfirmationDialog from 'ee/vue_merge_request_widget/components/merge_train_failed_pipeline_confirmation_dialog.vue';
import { trimText } from 'helpers/text_helper';
describe('MergeTrainFailedPipelineConfirmationDialog', () => {
let wrapper;
const GlModal = {
template: `
<div>
<slot></slot>
<slot name="modal-footer"></slot>
</div>
`,
methods: {
hide: jest.fn(),
},
};
const createComponent = () => {
wrapper = shallowMount(MergeTrainFailedPipelineConfirmationDialog, {
propsData: {
visible: true,
},
stubs: {
GlModal,
},
attachTo: document.body,
});
};
const findModal = () => wrapper.find(GlModal);
const findStartMergeTrainBtn = () => wrapper.find('[data-testid="start-merge-train"]');
const findCancelBtn = () => wrapper.find({ ref: 'cancelButton' });
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('should render informational text explaining why merging immediately can be dangerous', () => {
expect(trimText(wrapper.text())).toContain(
'The latest pipeline for this merge request has failed. Are you sure you want to attempt to merge?',
);
});
it('should emit the startMergeTrain event', () => {
findStartMergeTrainBtn().vm.$emit('click');
expect(wrapper.emitted('startMergeTrain')).toBeTruthy();
});
it('when the cancel button is clicked should emit cancel and call hide', () => {
jest.spyOn(findModal().vm, 'hide');
findCancelBtn().vm.$emit('click');
expect(wrapper.emitted('cancel')).toBeTruthy();
expect(findModal().vm.hide).toHaveBeenCalled();
});
it('should emit cancel when the hide event is emitted', () => {
findModal().vm.$emit('hide');
expect(wrapper.emitted('cancel')).toBeTruthy();
});
it('when modal is shown it will focus the cancel button', () => {
findCancelBtn().element.focus = jest.fn();
findModal().vm.$emit('shown');
expect(findCancelBtn().element.focus).toHaveBeenCalled();
});
});
import { GlLink, GlSprintf } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import MergeImmediatelyConfirmationDialog from 'ee/vue_merge_request_widget/components/merge_immediately_confirmation_dialog.vue';
import MergeTrainFailedPipelineConfirmationDialog from 'ee/vue_merge_request_widget/components/merge_train_failed_pipeline_confirmation_dialog.vue';
import MergeTrainHelperText from 'ee/vue_merge_request_widget/components/merge_train_helper_text.vue';
import { MERGE_DISABLED_TEXT_UNAPPROVED } from 'ee/vue_merge_request_widget/mixins/ready_to_merge';
import ReadyToMerge from '~/vue_merge_request_widget/components/states/ready_to_merge.vue';
......@@ -69,6 +70,7 @@ describe('ReadyToMerge', () => {
MergeTrainHelperText,
GlSprintf,
GlLink,
MergeTrainFailedPipelineConfirmationDialog,
},
});
......@@ -88,6 +90,8 @@ describe('ReadyToMerge', () => {
findMergeTrainHelperText().find('[data-testid="documentation-link"]');
const findFailedPipelineMergeTrainText = () =>
wrapper.find('[data-testid="failed-pipeline-merge-train-text"]');
const findMergeTrainFailedPipelineConfirmationDialog = () =>
wrapper.findComponent(MergeTrainFailedPipelineConfirmationDialog);
afterEach(() => {
if (wrapper?.destroy) {
......@@ -323,6 +327,32 @@ describe('ReadyToMerge', () => {
});
});
describe('merge train failed confirmation dialog', () => {
it.each`
mergeStrategy | isPipelineFailed | isVisible
${MT_MERGE_STRATEGY} | ${true} | ${true}
${MT_MERGE_STRATEGY} | ${false} | ${false}
${MTWPS_MERGE_STRATEGY} | ${true} | ${false}
${MWPS_MERGE_STRATEGY} | ${true} | ${false}
`(
'with merge stragtegy $mergeStrategy and pipeline failed status of $isPipelineFailed we should show the modal: $isVisible',
async ({ mergeStrategy, isPipelineFailed, isVisible }) => {
factory({ preferredAutoMergeStrategy: mergeStrategy, isPipelineFailed });
const modalConfirmation = findMergeTrainFailedPipelineConfirmationDialog();
if (!isVisible) {
// need to mock if we don't show modal
// to prevent internals from being invoked
vm.handleMergeButtonClick = jest.fn();
}
await findMergeButton().vm.$emit('click');
expect(modalConfirmation.props('visible')).toBe(isVisible);
},
);
});
describe('merge immediately warning dialog', () => {
let dialog;
......
......@@ -336,7 +336,7 @@ module API
lines = Gitlab::Diff::Parser.new.parse(diff.diff.each_line)
lines.each do |line|
next unless line.new_pos == params[:line] && line.type == params[:line_type]
next unless line.line == params[:line] && line.type == params[:line_type]
break opts[:line_code] = Gitlab::Git.diff_line_code(diff.new_path, line.new_pos, line.old_pos)
end
......
......@@ -5,7 +5,7 @@ module API
class CommitNote < Grape::Entity
expose :note
expose(:path) { |note| note.diff_file.try(:file_path) if note.diff_note? }
expose(:line) { |note| note.diff_line.try(:new_line) if note.diff_note? }
expose(:line) { |note| note.diff_line.try(:line) if note.diff_note? }
expose(:line_type) { |note| note.diff_line.try(:type) if note.diff_note? }
expose :author, using: Entities::UserBasic
expose :created_at
......
......@@ -35,12 +35,6 @@ module Gitlab
def hosts=(hosts)
@mutex.synchronize do
::Gitlab::Database::LoadBalancing::Logger.info(
event: :host_list_update,
message: "Updating the host list for service discovery",
host_list_length: hosts.length,
old_host_list_length: @hosts.length
)
@hosts = hosts
unsafe_shuffle
end
......
......@@ -13,11 +13,17 @@ module Gitlab
# balancer with said hosts. Requests may continue to use the old hosts
# until they complete.
class ServiceDiscovery
EmptyDnsResponse = Class.new(StandardError)
attr_reader :interval, :record, :record_type, :disconnect_timeout,
:load_balancer
MAX_SLEEP_ADJUSTMENT = 10
MAX_DISCOVERY_RETRIES = 3
RETRY_DELAY_RANGE = (0.1..0.2).freeze
RECORD_TYPES = {
'A' => Net::DNS::A,
'SRV' => Net::DNS::SRV
......@@ -76,15 +82,21 @@ module Gitlab
end
def perform_service_discovery
refresh_if_necessary
rescue StandardError => error
# Any exceptions that might occur should be reported to
# Sentry, instead of silently terminating this thread.
Gitlab::ErrorTracking.track_exception(error)
Gitlab::AppLogger.error(
"Service discovery encountered an error: #{error.message}"
)
MAX_DISCOVERY_RETRIES.times do
return refresh_if_necessary
rescue StandardError => error
# Any exceptions that might occur should be reported to
# Sentry, instead of silently terminating this thread.
Gitlab::ErrorTracking.track_exception(error)
Gitlab::AppLogger.error(
"Service discovery encountered an error: #{error.message}"
)
# Slightly randomize the retry delay so that, in the case of a total
# dns outage, all starting services do not pressure the dns server at the same time.
sleep(rand(RETRY_DELAY_RANGE))
end
interval
end
......@@ -99,7 +111,22 @@ module Gitlab
current = addresses_from_load_balancer
replace_hosts(from_dns) if from_dns != current
if from_dns != current
::Gitlab::Database::LoadBalancing::Logger.info(
event: :host_list_update,
message: "Updating the host list for service discovery",
host_list_length: from_dns.length,
old_host_list_length: current.length
)
replace_hosts(from_dns)
else
::Gitlab::Database::LoadBalancing::Logger.info(
event: :host_list_unchanged,
message: "Unchanged host list for service discovery",
host_list_length: from_dns.length,
old_host_list_length: current.length
)
end
interval
end
......@@ -141,6 +168,8 @@ module Gitlab
addresses_from_srv_record(response)
end
raise EmptyDnsResponse if addresses.empty?
# Addresses are sorted so we can directly compare the old and new
# addresses, without having to use any additional data structures.
[new_wait_time_for(resources), addresses.sort]
......
......@@ -6,9 +6,8 @@ module Gitlab
class Context
attr_reader :context
LOG_DEPTH_THRESHOLD = 4 # 3 nested subtransactions + 1 real transaction
LOG_SAVEPOINTS_THRESHOLD = 5 # 5 `SAVEPOINTS` created in sequence or nested
LOG_DURATION_S_THRESHOLD = 120 # 2 minutes long transaction
LOG_SAVEPOINTS_THRESHOLD = 1 # 1 `SAVEPOINT` created in a transaction
LOG_DURATION_S_THRESHOLD = 120 # transaction that is running for 2 minutes or longer
LOG_THROTTLE_DURATION = 1
def initialize
......@@ -19,6 +18,10 @@ module Gitlab
@context[:start_time] = current_timestamp
end
def set_depth(depth)
@context[:depth] = [@context[:depth].to_i, depth].max
end
def increment_savepoints
@context[:savepoints] = @context[:savepoints].to_i + 1
end
......@@ -31,10 +34,6 @@ module Gitlab
@context[:releases] = @context[:releases].to_i + 1
end
def set_depth(depth)
@context[:depth] = [@context[:depth].to_i, depth].max
end
def track_sql(sql)
(@context[:queries] ||= []).push(sql)
end
......@@ -45,10 +44,6 @@ module Gitlab
current_timestamp - @context[:start_time]
end
def depth_threshold_exceeded?
@context[:depth].to_i >= LOG_DEPTH_THRESHOLD
end
def savepoints_threshold_exceeded?
@context[:savepoints].to_i >= LOG_SAVEPOINTS_THRESHOLD
end
......@@ -57,16 +52,10 @@ module Gitlab
duration.to_i >= LOG_DURATION_S_THRESHOLD
end
def log_savepoints?
depth_threshold_exceeded? || savepoints_threshold_exceeded?
end
def log_duration?
duration_threshold_exceeded?
end
def should_log?
!logged_already? && (log_savepoints? || log_duration?)
return false if logged_already?
savepoints_threshold_exceeded? || duration_threshold_exceeded?
end
def commit
......
......@@ -21,7 +21,7 @@ module Gitlab
context.set_start_time
context.set_depth(0)
context.track_sql(event.payload[:sql])
elsif cmd.start_with?('SAVEPOINT ')
elsif cmd.start_with?('SAVEPOINT', 'EXCEPTION')
context.set_depth(manager.open_transactions)
context.increment_savepoints
elsif cmd.start_with?('ROLLBACK TO SAVEPOINT')
......
......@@ -898,17 +898,7 @@ module Gitlab
# This guard avoids Gitaly log/error spam
raise NoRepository, 'repository does not exist' unless exists?
if Feature.enabled?(:set_full_path)
gitaly_repository_client.set_full_path(full_path)
else
set_config('gitlab.fullpath' => full_path)
end
end
def set_config(entries)
wrapped_gitaly_errors do
gitaly_repository_client.set_config(entries)
end
gitaly_repository_client.set_full_path(full_path)
end
def disconnect_alternates
......
......@@ -264,25 +264,6 @@ module Gitlab
nil
end
def set_config(entries)
return if entries.empty?
request = Gitaly::SetConfigRequest.new(repository: @gitaly_repo)
entries.each do |key, value|
request.entries << build_set_config_entry(key, value)
end
GitalyClient.call(
@storage,
:repository_service,
:set_config,
request,
timeout: GitalyClient.fast_timeout
)
nil
end
def license_short_name
request = Gitaly::FindLicenseRequest.new(repository: @gitaly_repo)
......
......@@ -4379,6 +4379,9 @@ msgstr ""
msgid "Are you sure you want to %{action} %{name}?"
msgstr ""
msgid "Are you sure you want to attempt to merge?"
msgstr ""
msgid "Are you sure you want to cancel editing this comment?"
msgstr ""
......@@ -20784,6 +20787,9 @@ msgstr ""
msgid "MemberInviteEmail|%{member_name} invited you to join GitLab"
msgstr ""
msgid "MemberInviteEmail|I've invited you to join me in GitLab"
msgstr ""
msgid "MemberInviteEmail|Invitation to join the %{project_or_group} %{project_or_group_name}"
msgstr ""
......@@ -33334,6 +33340,9 @@ msgstr ""
msgid "The latest pipeline for this merge request did not complete successfully."
msgstr ""
msgid "The latest pipeline for this merge request has failed."
msgstr ""
msgid "The license key is invalid. Make sure it is exactly as you received it from GitLab Inc."
msgstr ""
......
......@@ -23,10 +23,6 @@ RSpec.describe Admin::RunnersController do
describe '#show' do
render_views
before do
stub_feature_flags(runner_detailed_view_vue_ui: false)
end
let_it_be(:project) { create(:project) }
let_it_be(:project_two) { create(:project) }
......@@ -61,30 +57,6 @@ RSpec.describe Admin::RunnersController do
expect(response).to have_gitlab_http_status(:ok)
end
describe 'Cost factors values' do
context 'when it is Gitlab.com' do
before do
expect(Gitlab).to receive(:com?).at_least(:once) { true }
end
it 'renders cost factors fields' do
get :show, params: { id: runner.id }
expect(response.body).to match /Private projects Minutes cost factor/
expect(response.body).to match /Public projects Minutes cost factor/
end
end
context 'when it is not Gitlab.com' do
it 'does not show cost factor fields' do
get :show, params: { id: runner.id }
expect(response.body).not_to match /Private projects Minutes cost factor/
expect(response.body).not_to match /Public projects Minutes cost factor/
end
end
end
end
describe '#update' do
......
......@@ -120,6 +120,29 @@ RSpec.describe InvitesController do
end
end
context 'when it is part of the invite_email_from experiment' do
let(:extra_params) { { invite_type: 'initial_email', experiment_name: 'invite_email_from' } }
it 'tracks the initial join click from email' do
experiment = double(track: true)
allow(controller).to receive(:experiment).with(:invite_email_from, actor: member).and_return(experiment)
request
expect(experiment).to have_received(:track).with(:join_clicked)
end
context 'when member does not exist' do
let(:raw_invite_token) { '_bogus_token_' }
it 'does not track the experiment' do
expect(controller).not_to receive(:experiment).with(:invite_email_from, actor: member)
request
end
end
end
context 'when member does not exist' do
let(:raw_invite_token) { '_bogus_token_' }
......@@ -147,8 +170,9 @@ RSpec.describe InvitesController do
end
context 'when it is not part of our invite email experiment' do
it 'does not track via experiment' do
it 'does not track via experiment', :aggregate_failures do
expect(controller).not_to receive(:experiment).with(:invite_email_preview_text, actor: member)
expect(controller).not_to receive(:experiment).with(:invite_email_from, actor: member)
request
end
......
......@@ -227,6 +227,40 @@ RSpec.describe RegistrationsController do
end
end
end
context 'with the invite_email_preview_text experiment', :experiment do
let(:extra_session_params) { { invite_email_experiment_name: 'invite_email_from' } }
context 'when member and invite_email_experiment_name exists from the session key value' do
it 'tracks the invite acceptance' do
expect(experiment(:invite_email_from)).to track(:accepted)
.with_context(actor: member)
.on_next_instance
subject
end
end
context 'when member does not exist from the session key value' do
let(:originating_member_id) { -1 }
it 'does not track invite acceptance' do
expect(experiment(:invite_email_from)).not_to track(:accepted)
subject
end
end
context 'when invite_email_experiment_name does not exist from the session key value' do
let(:extra_session_params) { {} }
it 'does not track invite acceptance' do
expect(experiment(:invite_email_from)).not_to track(:accepted)
subject
end
end
end
end
context 'when invite email matches email used on registration' do
......
......@@ -216,6 +216,20 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do
end
end
context 'with invite email acceptance for the invite_email_from experiment', :experiment do
let(:extra_params) do
{ invite_type: Emails::Members::INITIAL_INVITE, experiment_name: 'invite_email_from' }
end
it 'tracks the accepted invite' do
expect(experiment(:invite_email_from)).to track(:accepted)
.with_context(actor: group_invite)
.on_next_instance
fill_in_sign_up_form(new_user)
end
end
it 'signs up and redirects to the group activity page with all the project/groups invitation automatically accepted' do
fill_in_sign_up_form(new_user)
fill_in_welcome_form
......
import { GlIntersectionObserver } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import '~/behaviors/markdown/render_gfm';
import IssuableApp from '~/issue_show/components/app.vue';
import DescriptionComponent from '~/issue_show/components/description.vue';
......@@ -33,13 +34,17 @@ describe('Issuable output', () => {
let realtimeRequestCount = 0;
let wrapper;
const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]');
const findLockedBadge = () => wrapper.find('[data-testid="locked"]');
const findConfidentialBadge = () => wrapper.find('[data-testid="confidential"]');
const findStickyHeader = () => wrapper.findByTestId('issue-sticky-header');
const findLockedBadge = () => wrapper.findByTestId('locked');
const findConfidentialBadge = () => wrapper.findByTestId('confidential');
const findHiddenBadge = () => wrapper.findByTestId('hidden');
const findAlert = () => wrapper.find('.alert');
const mountComponent = (props = {}, options = {}, data = {}) => {
wrapper = mount(IssuableApp, {
wrapper = mountExtended(IssuableApp, {
directives: {
GlTooltip: createMockDirective(),
},
propsData: { ...appProps, ...props },
provide: {
fullPath: 'gitlab-org/incidents',
......@@ -539,8 +544,8 @@ describe('Issuable output', () => {
it.each`
title | isConfidential
${'does not show confidential badge when issue is not confidential'} | ${true}
${'shows confidential badge when issue is confidential'} | ${false}
${'does not show confidential badge when issue is not confidential'} | ${false}
${'shows confidential badge when issue is confidential'} | ${true}
`('$title', async ({ isConfidential }) => {
wrapper.setProps({ isConfidential });
......@@ -551,8 +556,8 @@ describe('Issuable output', () => {
it.each`
title | isLocked
${'does not show locked badge when issue is not locked'} | ${true}
${'shows locked badge when issue is locked'} | ${false}
${'does not show locked badge when issue is not locked'} | ${false}
${'shows locked badge when issue is locked'} | ${true}
`('$title', async ({ isLocked }) => {
wrapper.setProps({ isLocked });
......@@ -560,6 +565,27 @@ describe('Issuable output', () => {
expect(findLockedBadge().exists()).toBe(isLocked);
});
it.each`
title | isHidden
${'does not show hidden badge when issue is not hidden'} | ${false}
${'shows hidden badge when issue is hidden'} | ${true}
`('$title', async ({ isHidden }) => {
wrapper.setProps({ isHidden });
await nextTick();
const hiddenBadge = findHiddenBadge();
expect(hiddenBadge.exists()).toBe(isHidden);
if (isHidden) {
expect(hiddenBadge.attributes('title')).toBe(
'This issue is hidden because its author has been banned',
);
expect(getBinding(hiddenBadge.element, 'gl-tooltip')).not.toBeUndefined();
}
});
});
});
......
......@@ -54,7 +54,7 @@ describe('RunnerUpdateForm', () => {
? ACCESS_LEVEL_REF_PROTECTED
: ACCESS_LEVEL_NOT_PROTECTED,
runUntagged: findRunUntaggedCheckbox().element.checked,
locked: findLockedCheckbox().element.checked,
locked: findLockedCheckbox().element?.checked || false,
ipAddress: findIpInput().element.value,
maximumTimeout: findMaxJobTimeoutInput().element.value || null,
tagList: findTagsInput().element.value.split(',').filter(Boolean),
......@@ -153,15 +153,15 @@ describe('RunnerUpdateForm', () => {
});
it.each`
runnerType | attrDisabled | outcome
${INSTANCE_TYPE} | ${'disabled'} | ${'disabled'}
${GROUP_TYPE} | ${'disabled'} | ${'disabled'}
${PROJECT_TYPE} | ${undefined} | ${'enabled'}
`(`When runner is $runnerType, locked field is $outcome`, ({ runnerType, attrDisabled }) => {
runnerType | exists | outcome
${INSTANCE_TYPE} | ${false} | ${'hidden'}
${GROUP_TYPE} | ${false} | ${'hidden'}
${PROJECT_TYPE} | ${true} | ${'shown'}
`(`When runner is $runnerType, locked field is $outcome`, ({ runnerType, exists }) => {
const runner = { ...mockRunner, runnerType };
createComponent({ props: { runner } });
expect(findLockedCheckbox().attributes('disabled')).toBe(attrDisabled);
expect(findLockedCheckbox().exists()).toBe(exists);
});
describe('On submit, runner gets updated', () => {
......
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createStore as createMrStore } from '~/mr_notes/stores';
import createIssueStore from '~/notes/stores';
import IssuableHeaderWarnings from '~/vue_shared/components/issuable/issuable_header_warnings.vue';
......@@ -12,52 +14,53 @@ localVue.use(Vuex);
describe('IssuableHeaderWarnings', () => {
let wrapper;
let store;
const findConfidentialIcon = () => wrapper.find('[data-testid="confidential"]');
const findLockedIcon = () => wrapper.find('[data-testid="locked"]');
const findConfidentialIcon = () => wrapper.findByTestId('confidential');
const findLockedIcon = () => wrapper.findByTestId('locked');
const findHiddenIcon = () => wrapper.findByTestId('hidden');
const renderTestMessage = (renders) => (renders ? 'renders' : 'does not render');
const setLock = (locked) => {
store.getters.getNoteableData.discussion_locked = locked;
};
const setConfidential = (confidential) => {
store.getters.getNoteableData.confidential = confidential;
};
const createComponent = () => {
wrapper = shallowMount(IssuableHeaderWarnings, { store, localVue });
const createComponent = ({ store, provide }) => {
wrapper = shallowMountExtended(IssuableHeaderWarnings, {
store,
localVue,
provide,
directives: {
GlTooltip: createMockDirective(),
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
store = null;
});
describe.each`
issuableType
${ISSUABLE_TYPE_ISSUE} | ${ISSUABLE_TYPE_MR}
`(`when issuableType=$issuableType`, ({ issuableType }) => {
beforeEach(() => {
store = issuableType === ISSUABLE_TYPE_ISSUE ? createIssueStore() : createMrStore();
createComponent();
});
describe.each`
lockStatus | confidentialStatus
${true} | ${true}
${true} | ${false}
${false} | ${true}
${false} | ${false}
lockStatus | confidentialStatus | hiddenStatus
${true} | ${true} | ${false}
${true} | ${false} | ${false}
${false} | ${true} | ${false}
${false} | ${false} | ${false}
${true} | ${true} | ${true}
${true} | ${false} | ${true}
${false} | ${true} | ${true}
${false} | ${false} | ${true}
`(
`when locked=$lockStatus and confidential=$confidentialStatus`,
({ lockStatus, confidentialStatus }) => {
`when locked=$lockStatus, confidential=$confidentialStatus, and hidden=$hiddenStatus`,
({ lockStatus, confidentialStatus, hiddenStatus }) => {
const store = issuableType === ISSUABLE_TYPE_ISSUE ? createIssueStore() : createMrStore();
beforeEach(() => {
setLock(lockStatus);
setConfidential(confidentialStatus);
store.getters.getNoteableData.confidential = confidentialStatus;
store.getters.getNoteableData.discussion_locked = lockStatus;
createComponent({ store, provide: { hidden: hiddenStatus } });
});
it(`${renderTestMessage(lockStatus)} the locked icon`, () => {
......@@ -67,6 +70,19 @@ describe('IssuableHeaderWarnings', () => {
it(`${renderTestMessage(confidentialStatus)} the confidential icon`, () => {
expect(findConfidentialIcon().exists()).toBe(confidentialStatus);
});
it(`${renderTestMessage(confidentialStatus)} the hidden icon`, () => {
const hiddenIcon = findHiddenIcon();
expect(hiddenIcon.exists()).toBe(hiddenStatus);
if (hiddenStatus) {
expect(hiddenIcon.attributes('title')).toBe(
'This issue is hidden because its author has been banned',
);
expect(getBinding(hiddenIcon.element, 'gl-tooltip')).not.toBeUndefined();
}
});
},
);
});
......
......@@ -285,7 +285,8 @@ RSpec.describe IssuablesHelper do
initialDescriptionText: 'issue text',
initialTaskStatus: '0 of 0 tasks completed',
issueType: 'issue',
iid: issue.iid.to_s
iid: issue.iid.to_s,
isHidden: false
}
expect(helper.issuable_initial_data(issue)).to match(hash_including(expected_data))
end
......
......@@ -410,4 +410,55 @@ RSpec.describe IssuesHelper do
end
end
end
describe '#issue_hidden?' do
context 'when issue is hidden' do
let_it_be(:banned_user) { build(:user, :banned) }
let_it_be(:hidden_issue) { build(:issue, author: banned_user) }
context 'when `ban_user_feature_flag` feature flag is enabled' do
it 'returns `true`' do
expect(helper.issue_hidden?(hidden_issue)).to eq(true)
end
end
context 'when `ban_user_feature_flag` feature flag is disabled' do
before do
stub_feature_flags(ban_user_feature_flag: false)
end
it 'returns `false`' do
expect(helper.issue_hidden?(hidden_issue)).to eq(false)
end
end
end
context 'when issue is not hidden' do
it 'returns `false`' do
expect(helper.issue_hidden?(issue)).to eq(false)
end
end
end
describe '#hidden_issue_icon' do
let_it_be(:banned_user) { build(:user, :banned) }
let_it_be(:hidden_issue) { build(:issue, author: banned_user) }
let_it_be(:mock_svg) { '<svg></svg>'.html_safe }
before do
allow(helper).to receive(:sprite_icon).and_return(mock_svg)
end
context 'when issue is hidden' do
it 'returns icon with tooltip' do
expect(helper.hidden_issue_icon(hidden_issue)).to eq("<span class=\"has-tooltip\" title=\"This issue is hidden because its author has been banned\">#{mock_svg}</span>")
end
end
context 'when issue is not hidden' do
it 'returns `nil`' do
expect(helper.hidden_issue_icon(issue)).to be_nil
end
end
end
end
......@@ -70,6 +70,28 @@ RSpec.describe NotifyHelper do
expect(helper.invited_join_url(token, member))
.to eq("http://test.host/-/invites/#{token}?experiment_name=invite_email_preview_text&invite_type=initial_email")
end
context 'when invite_email_from is enabled' do
before do
stub_experiments(invite_email_from: :control)
end
it 'has correct params' do
expect(helper.invited_join_url(token, member))
.to eq("http://test.host/-/invites/#{token}?experiment_name=invite_email_from&invite_type=initial_email")
end
end
end
context 'when invite_email_from is enabled' do
before do
stub_experiments(invite_email_from: :control)
end
it 'has correct params' do
expect(helper.invited_join_url(token, member))
.to eq("http://test.host/-/invites/#{token}?experiment_name=invite_email_from&invite_type=initial_email")
end
end
context 'when invite_email_preview_text is disabled' do
......
......@@ -69,18 +69,69 @@ RSpec.describe Gitlab::Database::LoadBalancing::ServiceDiscovery do
end
describe '#perform_service_discovery' do
it 'reports exceptions to Sentry' do
error = StandardError.new
context 'without any failures' do
it 'runs once' do
expect(service)
.to receive(:refresh_if_necessary).once
expect(service)
.to receive(:refresh_if_necessary)
.and_raise(error)
expect(service).not_to receive(:sleep)
expect(Gitlab::ErrorTracking)
.to receive(:track_exception)
.with(error)
expect(Gitlab::ErrorTracking).not_to receive(:track_exception)
service.perform_service_discovery
service.perform_service_discovery
end
end
context 'with failures' do
before do
allow(Gitlab::ErrorTracking).to receive(:track_exception)
allow(service).to receive(:sleep)
end
let(:valid_retry_sleep_duration) { satisfy { |val| described_class::RETRY_DELAY_RANGE.include?(val) } }
it 'retries service discovery when under the retry limit' do
error = StandardError.new
expect(service)
.to receive(:refresh_if_necessary)
.and_raise(error).exactly(described_class::MAX_DISCOVERY_RETRIES - 1).times.ordered
expect(service)
.to receive(:sleep).with(valid_retry_sleep_duration)
.exactly(described_class::MAX_DISCOVERY_RETRIES - 1).times
expect(service).to receive(:refresh_if_necessary).and_return(45).ordered
expect(service.perform_service_discovery).to eq(45)
end
it 'does not retry service discovery after exceeding the limit' do
error = StandardError.new
expect(service)
.to receive(:refresh_if_necessary)
.and_raise(error).exactly(described_class::MAX_DISCOVERY_RETRIES).times
expect(service)
.to receive(:sleep).with(valid_retry_sleep_duration)
.exactly(described_class::MAX_DISCOVERY_RETRIES).times
service.perform_service_discovery
end
it 'reports exceptions to Sentry' do
error = StandardError.new
expect(service)
.to receive(:refresh_if_necessary)
.and_raise(error).exactly(described_class::MAX_DISCOVERY_RETRIES).times
expect(Gitlab::ErrorTracking)
.to receive(:track_exception)
.with(error).exactly(described_class::MAX_DISCOVERY_RETRIES).times
service.perform_service_discovery
end
end
end
......@@ -224,6 +275,16 @@ RSpec.describe Gitlab::Database::LoadBalancing::ServiceDiscovery do
expect(service.addresses_from_dns).to eq([90, addresses])
end
end
context 'when the resolver returns an empty response' do
let(:packet) { double(:packet, answer: []) }
let(:record_type) { 'A' }
it 'raises EmptyDnsResponse' do
expect { service.addresses_from_dns }.to raise_error(Gitlab::Database::LoadBalancing::ServiceDiscovery::EmptyDnsResponse)
end
end
end
describe '#new_wait_time_for' do
......
......@@ -70,24 +70,6 @@ RSpec.describe Gitlab::Database::Transaction::Context do
it { expect(subject.duration).to be >= 0 }
end
context 'when depth is low' do
it 'does not log data upon COMMIT' do
expect(subject).not_to receive(:application_info)
subject.commit
end
it 'does not log data upon ROLLBACK' do
expect(subject).not_to receive(:application_info)
subject.rollback
end
it '#should_log? returns false' do
expect(subject.should_log?).to be false
end
end
shared_examples 'logs transaction data' do
it 'logs once upon COMMIT' do
expect(subject).to receive(:application_info).and_call_original
......@@ -116,17 +98,9 @@ RSpec.describe Gitlab::Database::Transaction::Context do
end
end
context 'when depth exceeds threshold' do
before do
subject.set_depth(described_class::LOG_DEPTH_THRESHOLD + 1)
end
it_behaves_like 'logs transaction data'
end
context 'when savepoints count exceeds threshold' do
before do
data[:savepoints] = described_class::LOG_SAVEPOINTS_THRESHOLD + 1
data[:savepoints] = 1
end
it_behaves_like 'logs transaction data'
......
......@@ -1710,83 +1710,42 @@ RSpec.describe Gitlab::Git::Repository, :seed_helper do
end
describe '#set_full_path' do
shared_examples '#set_full_path' do
before do
repository_rugged.config["gitlab.fullpath"] = repository_path
end
context 'is given a path' do
it 'writes it to disk' do
repository.set_full_path(full_path: "not-the/real-path.git")
config = File.read(File.join(repository_path, "config"))
expect(config).to include("[gitlab]")
expect(config).to include("fullpath = not-the/real-path.git")
end
end
context 'it is given an empty path' do
it 'does not write it to disk' do
repository.set_full_path(full_path: "")
config = File.read(File.join(repository_path, "config"))
expect(config).to include("[gitlab]")
expect(config).to include("fullpath = #{repository_path}")
end
end
before do
repository_rugged.config["gitlab.fullpath"] = repository_path
end
context 'repository does not exist' do
it 'raises NoRepository and does not call Gitaly WriteConfig' do
repository = Gitlab::Git::Repository.new('default', 'does/not/exist.git', '', 'group/project')
context 'is given a path' do
it 'writes it to disk' do
repository.set_full_path(full_path: "not-the/real-path.git")
expect(repository.gitaly_repository_client).not_to receive(:set_full_path)
config = File.read(File.join(repository_path, "config"))
expect do
repository.set_full_path(full_path: 'foo/bar.git')
end.to raise_error(Gitlab::Git::Repository::NoRepository)
end
expect(config).to include("[gitlab]")
expect(config).to include("fullpath = not-the/real-path.git")
end
end
context 'with :set_full_path enabled' do
before do
stub_feature_flags(set_full_path: true)
end
context 'it is given an empty path' do
it 'does not write it to disk' do
repository.set_full_path(full_path: "")
it_behaves_like '#set_full_path'
end
config = File.read(File.join(repository_path, "config"))
context 'with :set_full_path disabled' do
before do
stub_feature_flags(set_full_path: false)
expect(config).to include("[gitlab]")
expect(config).to include("fullpath = #{repository_path}")
end
it_behaves_like '#set_full_path'
end
end
describe '#set_config' do
let(:repository) { mutable_repository }
let(:entries) do
{
'test.foo1' => 'bla bla',
'test.foo2' => 1234,
'test.foo3' => true
}
end
it 'can set config settings' do
expect(repository.set_config(entries)).to be_nil
context 'repository does not exist' do
it 'raises NoRepository and does not call Gitaly WriteConfig' do
repository = Gitlab::Git::Repository.new('default', 'does/not/exist.git', '', 'group/project')
expect(repository_rugged.config['test.foo1']).to eq('bla bla')
expect(repository_rugged.config['test.foo2']).to eq('1234')
expect(repository_rugged.config['test.foo3']).to eq('true')
end
expect(repository.gitaly_repository_client).not_to receive(:set_full_path)
after do
entries.keys.each { |k| repository_rugged.config.delete(k) }
expect do
repository.set_full_path(full_path: 'foo/bar.git')
end.to raise_error(Gitlab::Git::Repository::NoRepository)
end
end
end
......
......@@ -834,6 +834,35 @@ RSpec.describe Notify do
invite_type: Emails::Members::INITIAL_INVITE,
experiment_name: 'invite_email_preview_text'))
end
it 'tracks the sent invite' do
expect(experiment(:invite_email_preview_text)).to track(:assignment)
.with_context(actor: project_member)
.on_next_instance
invite_email.deliver_now
end
end
context 'with invite_email_from enabled', :experiment do
before do
stub_experiments(invite_email_from: :control)
end
it 'has the correct invite_url with params' do
is_expected.to have_link('Join now',
href: invite_url(project_member.invite_token,
invite_type: Emails::Members::INITIAL_INVITE,
experiment_name: 'invite_email_from'))
end
it 'tracks the sent invite' do
expect(experiment(:invite_email_from)).to track(:assignment)
.with_context(actor: project_member)
.on_next_instance
invite_email.deliver_now
end
end
context 'when invite email sent is tracked', :snowplow do
......
This diff is collapsed.
......@@ -1879,6 +1879,26 @@ RSpec.describe API::Commits do
expect(json_response['line_type']).to eq('new')
end
it 'correctly adds a note for the "old" line type' do
commit = project.repository.commit("markdown")
commit_id = commit.id
route = "/projects/#{project_id}/repository/commits/#{commit_id}/comments"
post api(route, current_user), params: {
note: 'My comment',
path: commit.raw_diffs.first.old_path,
line: 4,
line_type: 'old'
}
expect(response).to have_gitlab_http_status(:created)
expect(response).to match_response_schema('public_api/v4/commit_note')
expect(json_response['note']).to eq('My comment')
expect(json_response['path']).to eq(commit.raw_diffs.first.old_path)
expect(json_response['line']).to eq(4)
expect(json_response['line_type']).to eq('old')
end
context 'when ref does not exist' do
let(:commit_id) { 'unknown' }
......
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