Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-03-14 12:07:59 +00:00
parent 6aa920eeb4
commit fd27e4f95b
73 changed files with 1060 additions and 178 deletions

View File

@ -1 +1 @@
14.8.1
14.9.0

View File

@ -1,5 +1,5 @@
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
import * as lowlight from 'lowlight';
import { lowlight } from 'lowlight/lib/all';
const extractLanguage = (element) => element.getAttribute('lang');

View File

@ -28,8 +28,12 @@ export const overridesTabTitle = s__('Integrations|Projects using custom setting
export const integrationFormSections = {
CONNECTION: 'connection',
JIRA_TRIGGER: 'jira_trigger',
JIRA_ISSUES: 'jira_issues',
};
export const integrationFormSectionComponents = {
[integrationFormSections.CONNECTION]: 'IntegrationSectionConnection',
[integrationFormSections.JIRA_TRIGGER]: 'IntegrationSectionJiraTrigger',
[integrationFormSections.JIRA_ISSUES]: 'IntegrationSectionJiraIssues',
};

View File

@ -39,6 +39,14 @@ export default {
import(
/* webpackChunkName: 'integrationSectionConnection' */ '~/integrations/edit/components/sections/connection.vue'
),
IntegrationSectionJiraIssues: () =>
import(
/* webpackChunkName: 'integrationSectionJiraIssues' */ '~/integrations/edit/components/sections/jira_issues.vue'
),
IntegrationSectionJiraTrigger: () =>
import(
/* webpackChunkName: 'integrationSectionJiraTrigger' */ '~/integrations/edit/components/sections/jira_trigger.vue'
),
GlButton,
GlForm,
},
@ -47,6 +55,11 @@ export default {
SafeHtml,
},
mixins: [glFeatureFlagsMixin()],
provide() {
return {
hasSections: this.hasSections,
};
},
inject: {
helpHtml: {
default: '',
@ -208,9 +221,9 @@ export default {
<template v-if="hasSections">
<div
v-for="section in customState.sections"
v-for="(section, index) in customState.sections"
:key="section.type"
class="gl-border-b gl-mb-5"
:class="{ 'gl-border-b gl-pb-3 gl-mb-6': index !== customState.sections.length - 1 }"
data-testid="integration-section"
>
<div class="row">
@ -225,6 +238,7 @@ export default {
:fields="fieldsForSection(section)"
:is-validated="isValidated"
@toggle-integration-active="onToggleIntegrationState"
@request-jira-issue-types="onRequestJiraIssueTypes"
/>
</div>
</div>
@ -244,13 +258,13 @@ export default {
@toggle-integration-active="onToggleIntegrationState"
/>
<jira-trigger-fields
v-if="isJira"
v-if="isJira && !hasSections"
:key="`${currentKey}-jira-trigger-fields`"
v-bind="propsSource.triggerFieldsProps"
:is-validated="isValidated"
/>
<trigger-fields
v-else-if="propsSource.triggerEvents.length"
v-else-if="propsSource.triggerEvents.length && !hasSections"
:key="`${currentKey}-trigger-fields`"
:events="propsSource.triggerEvents"
:type="propsSource.type"
@ -262,15 +276,18 @@ export default {
:is-validated="isValidated"
/>
<jira-issues-fields
v-if="isJira && !isInstanceOrGroupLevel"
v-if="isJira && !isInstanceOrGroupLevel && !hasSections"
:key="`${currentKey}-jira-issues-fields`"
v-bind="propsSource.jiraIssuesProps"
:is-validated="isValidated"
@request-jira-issue-types="onRequestJiraIssueTypes"
/>
</div>
</div>
<div v-if="isEditable" class="row">
<div :class="hasSections ? 'col' : 'col-lg-8 offset-lg-4'">
<div
v-if="isEditable"
class="footer-block row-content-block gl-display-flex gl-justify-content-space-between"
>
<div>

View File

@ -16,6 +16,11 @@ export default {
JiraIssueCreationVulnerabilities: () =>
import('ee_component/integrations/edit/components/jira_issue_creation_vulnerabilities.vue'),
},
inject: {
hasSections: {
default: false,
},
},
props: {
showJiraIssuesIntegration: {
type: Boolean,
@ -101,9 +106,12 @@ export default {
<template>
<div>
<gl-form-group :label="$options.i18n.sectionTitle" label-for="jira-issue-settings">
<gl-form-group
:label="hasSections ? null : $options.i18n.sectionTitle"
label-for="jira-issue-settings"
>
<div id="jira-issue-settings">
<p>
<p v-if="!hasSections">
{{ $options.i18n.sectionDescription }}
</p>
<template v-if="showJiraIssuesIntegration">

View File

@ -62,6 +62,11 @@ export default {
GlLink,
GlSprintf,
},
inject: {
hasSections: {
default: false,
},
},
props: {
initialTriggerCommit: {
type: Boolean,
@ -134,12 +139,14 @@ export default {
<template>
<div>
<gl-form-group
:label="__('Trigger')"
:label="hasSections ? null : __('Trigger')"
label-for="service[trigger]"
:description="
s__(
'JiraService|When a Jira issue is mentioned in a commit or merge request, a remote link and comment (if enabled) will be created.',
)
hasSections
? null
: s__(
'JiraService|When a Jira issue is mentioned in a commit or merge request, a remote link and comment (if enabled) will be created.',
)
"
>
<input name="service[commit_events]" type="hidden" :value="triggerCommit || false" />

View File

@ -0,0 +1,33 @@
<script>
import { mapGetters } from 'vuex';
import JiraIssuesFields from '../jira_issues_fields.vue';
export default {
name: 'IntegrationSectionJiraIssues',
components: {
JiraIssuesFields,
},
props: {
isValidated: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
...mapGetters(['currentKey', 'propsSource']),
},
};
</script>
<template>
<div>
<jira-issues-fields
:key="`${currentKey}-jira-issues-fields`"
v-bind="propsSource.jiraIssuesProps"
:is-validated="isValidated"
@request-jira-issue-types="$emit('request-jira-issue-types')"
/>
</div>
</template>

View File

@ -0,0 +1,32 @@
<script>
import { mapGetters } from 'vuex';
import JiraTriggerFields from '../jira_trigger_fields.vue';
export default {
name: 'IntegrationSectionJiraTrigger',
components: {
JiraTriggerFields,
},
props: {
isValidated: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
...mapGetters(['currentKey', 'propsSource']),
},
};
</script>
<template>
<div>
<jira-trigger-fields
:key="`${currentKey}-jira-trigger-fields`"
v-bind="propsSource.triggerFieldsProps"
:is-validated="isValidated"
/>
</div>
</template>

View File

@ -174,6 +174,8 @@ export default {
});
if (errors.length > 0) {
this.isRetrying = false;
this.reportFailure(POST_FAILURE);
} else {
await this.$apollo.queries.pipeline.refetch();
@ -182,6 +184,8 @@ export default {
}
}
} catch {
this.isRetrying = false;
this.reportFailure(POST_FAILURE);
}
},

View File

@ -86,7 +86,7 @@ export default {
);
},
statusIconName() {
if (this.hasFetchError) return EXTENSION_ICONS.error;
if (this.hasFetchError) return EXTENSION_ICONS.failed;
if (this.isLoadingSummary) return null;
return this.statusIcon(this.collapsedData);

View File

@ -0,0 +1,39 @@
import { __, n__, s__, sprintf } from '~/locale';
const digitText = (bold = false) => (bold ? '%{strong_start}%d%{strong_end}' : '%d');
const noText = (bold = false) => (bold ? '%{strong_start}no%{strong_end}' : 'no');
export const TESTS_FAILED_STATUS = 'failed';
export const ERROR_STATUS = 'error';
export const i18n = {
label: s__('Reports|Test summary'),
loading: s__('Reports|Test summary results are loading'),
error: s__('Reports|Test summary failed to load results'),
fullReport: s__('Reports|Full report'),
noChanges: (bold) => s__(`Reports|${noText(bold)} changed test results`),
resultsString: (combinedString, resolvedString) =>
sprintf(s__('Reports|%{combinedString} and %{resolvedString}'), {
combinedString,
resolvedString,
}),
summaryText: (name, resultsString) =>
sprintf(__('%{name}: %{resultsString}'), { name, resultsString }),
failedClause: (failed, bold) =>
n__(`${digitText(bold)} failed`, `${digitText(bold)} failed`, failed),
erroredClause: (errored, bold) =>
n__(`${digitText(bold)} error`, `${digitText(bold)} errors`, errored),
resolvedClause: (resolved, bold) =>
n__(`${digitText(bold)} fixed test result`, `${digitText(bold)} fixed test results`, resolved),
totalClause: (total, bold) =>
n__(`${digitText(bold)} total test`, `${digitText(bold)} total tests`, total),
reportError: s__('Reports|An error occurred while loading report'),
reportErrorWithName: (name) =>
sprintf(s__('Reports|An error occurred while loading %{name} results'), { name }),
headReportParsingError: s__('Reports|Head report parsing error:'),
baseReportParsingError: s__('Reports|Base report parsing error:'),
};

View File

@ -0,0 +1,82 @@
import { uniqueId } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import { EXTENSION_ICONS } from '../../constants';
import { summaryTextBuilder, reportTextBuilder, reportSubTextBuilder } from './utils';
import { i18n, TESTS_FAILED_STATUS, ERROR_STATUS } from './constants';
export default {
name: 'WidgetTestSummary',
enablePolling: true,
i18n,
expandEvent: 'i_testing_summary_widget_total',
props: ['testResultsPath', 'headBlobPath', 'pipeline'],
computed: {
summary(data) {
if (data.parsingInProgress) {
return this.$options.i18n.loading;
}
if (data.hasSuiteError) {
return this.$options.i18n.error;
}
return summaryTextBuilder(this.$options.i18n.label, data.summary);
},
statusIcon(data) {
if (data.parsingInProgress) {
return null;
}
if (data.status === TESTS_FAILED_STATUS) {
return EXTENSION_ICONS.warning;
}
if (data.hasSuiteError) {
return EXTENSION_ICONS.failed;
}
return EXTENSION_ICONS.success;
},
tertiaryButtons() {
return [
{
text: this.$options.i18n.fullReport,
href: `${this.pipeline.path}/test_report`,
target: '_blank',
},
];
},
},
methods: {
fetchCollapsedData() {
return axios.get(this.testResultsPath).then(({ data = {}, status }) => {
return {
data: {
hasSuiteError: data.suites?.some((suite) => suite.status === ERROR_STATUS),
parsingInProgress: status === 204,
...data,
},
};
});
},
fetchFullData() {
return Promise.resolve(this.prepareReports());
},
suiteIcon(suite) {
if (suite.status === ERROR_STATUS) {
return EXTENSION_ICONS.error;
}
if (suite.status === TESTS_FAILED_STATUS) {
return EXTENSION_ICONS.failed;
}
return EXTENSION_ICONS.success;
},
prepareReports() {
return this.collapsedData.suites.map((suite) => {
return {
id: uniqueId('suite-'),
text: reportTextBuilder(suite),
subtext: reportSubTextBuilder(suite),
icon: {
name: this.suiteIcon(suite),
},
};
});
},
},
};

View File

@ -0,0 +1,55 @@
import { i18n } from './constants';
const textBuilder = (results, boldNumbers = false) => {
const { failed, errored, resolved, total } = results;
const failedOrErrored = (failed || 0) + (errored || 0);
const failedString = failed ? i18n.failedClause(failed, boldNumbers) : null;
const erroredString = errored ? i18n.erroredClause(errored, boldNumbers) : null;
const combinedString =
failed && errored ? `${failedString}, ${erroredString}` : failedString || erroredString;
const resolvedString = resolved ? i18n.resolvedClause(resolved, boldNumbers) : null;
const totalString = total ? i18n.totalClause(total, boldNumbers) : null;
let resultsString = i18n.noChanges(boldNumbers);
if (failedOrErrored) {
if (resolved) {
resultsString = i18n.resultsString(combinedString, resolvedString);
} else {
resultsString = combinedString;
}
} else if (resolved) {
resultsString = resolvedString;
}
return `${resultsString}, ${totalString}`;
};
export const summaryTextBuilder = (name = '', results = {}) => {
const resultsString = textBuilder(results, true);
return i18n.summaryText(name, resultsString);
};
export const reportTextBuilder = ({ name = '', summary = {}, status }) => {
if (!name) {
return i18n.reportError;
}
if (status === 'error') {
return i18n.reportErrorWithName(name);
}
const resultsString = textBuilder(summary);
return i18n.summaryText(name, resultsString);
};
export const reportSubTextBuilder = ({ suite_errors }) => {
const errors = [];
if (suite_errors?.head) {
errors.push(`${i18n.headReportParsingError} ${suite_errors.head}`);
}
if (suite_errors?.base) {
errors.push(`${i18n.baseReportParsingError} ${suite_errors.base}`);
}
return errors.join('<br />');
};

View File

@ -46,6 +46,7 @@ import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variab
import getStateQuery from './queries/get_state.query.graphql';
import terraformExtension from './extensions/terraform';
import accessibilityExtension from './extensions/accessibility';
import testReportExtension from './extensions/test_report';
export default {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
@ -190,6 +191,9 @@ export default {
shouldRenderTerraformPlans() {
return Boolean(this.mr?.terraformReportsPath);
},
shouldRenderTestReport() {
return Boolean(this.mr?.testResultsPath);
},
mergeError() {
let { mergeError } = this.mr;
@ -246,6 +250,11 @@ export default {
this.registerAccessibilityExtension();
}
},
shouldRenderTestReport(newVal) {
if (newVal) {
this.registerTestReportExtension();
}
},
},
mounted() {
MRWidgetService.fetchInitialData()
@ -491,6 +500,11 @@ export default {
registerExtension(accessibilityExtension);
}
},
registerTestReportExtension() {
if (this.shouldRenderTestReport && this.shouldShowExtension) {
registerExtension(testReportExtension);
}
},
},
};
</script>
@ -563,7 +577,7 @@ export default {
/>
<grouped-test-reports-app
v-if="mr.testResultsPath"
v-if="mr.testResultsPath && !shouldShowExtension"
class="js-reports-container"
:endpoint="mr.testResultsPath"
:head-blob-path="mr.headBlobPath"

View File

@ -17,10 +17,6 @@ module IssuableActions
def show
respond_to do |format|
format.html do
@show_crm_contacts = issuable.is_a?(Issue) && # rubocop:disable Gitlab/ModuleWithInstanceVariables
can?(current_user, :read_crm_contact, issuable.project.group) &&
CustomerRelations::Contact.exists_for_group?(issuable.project.group)
@issuable_sidebar = serializer.represent(issuable, serializer: 'sidebar') # rubocop:disable Gitlab/ModuleWithInstanceVariables
render 'show'
end

View File

@ -82,6 +82,10 @@ class Groups::ApplicationController < ApplicationController
def has_project_list?
false
end
def validate_root_group!
render_404 unless group.root?
end
end
Groups::ApplicationController.prepend_mod_with('Groups::ApplicationController')

View File

@ -3,6 +3,7 @@
class Groups::Crm::ContactsController < Groups::ApplicationController
feature_category :team_planning
before_action :validate_root_group!
before_action :authorize_read_crm_contact!
def new

View File

@ -3,6 +3,7 @@
class Groups::Crm::OrganizationsController < Groups::ApplicationController
feature_category :team_planning
before_action :validate_root_group!
before_action :authorize_read_crm_organization!
def new

View File

@ -161,14 +161,20 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def retry
::Ci::RetryPipelineWorker.perform_async(pipeline.id, current_user.id) # rubocop:disable CodeReuse/Worker
# Check for access before execution to allow for async execution while still returning access results
access_response = ::Ci::RetryPipelineService.new(@project, current_user).check_access(pipeline)
if access_response.error?
response = { json: { errors: [access_response.message] }, status: access_response.http_status }
else
response = { json: {}, status: :no_content }
::Ci::RetryPipelineWorker.perform_async(pipeline.id, current_user.id) # rubocop:disable CodeReuse/Worker
end
respond_to do |format|
format.html do
redirect_back_or_default default: project_pipelines_path(project)
format.json do
render response
end
format.json { head :no_content }
end
end

View File

@ -17,10 +17,11 @@ module Mutations
pipeline = authorized_find!(id: id)
project = pipeline.project
::Ci::RetryPipelineService.new(project, current_user).execute(pipeline)
service_response = ::Ci::RetryPipelineService.new(project, current_user).execute(pipeline)
{
pipeline: pipeline,
errors: errors_on_object(pipeline)
errors: errors_on_object(pipeline) + service_response.errors
}
end
end

View File

@ -2,7 +2,7 @@
module Groups
module CrmSettingsHelper
def crm_feature_flag_enabled?(group)
def crm_feature_available?(group)
Feature.enabled?(:customer_relations, group)
end
end

View File

@ -23,8 +23,9 @@ class CustomerRelations::Contact < ApplicationRecord
validates :last_name, presence: true, length: { maximum: 255 }
validates :email, length: { maximum: 255 }
validates :description, length: { maximum: 1024 }
validates :email, uniqueness: { scope: :group_id }
validate :validate_email_format
validate :unique_email_for_group_hierarchy
validate :validate_root_group
def self.reference_prefix
'[contact:'
@ -41,14 +42,13 @@ class CustomerRelations::Contact < ApplicationRecord
def self.find_ids_by_emails(group, emails)
raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK
where(group_id: group.self_and_ancestor_ids, email: emails)
.pluck(:id)
where(group: group, email: emails).pluck(:id)
end
def self.exists_for_group?(group)
return false unless group
exists?(group_id: group.self_and_ancestor_ids)
exists?(group: group)
end
private
@ -59,13 +59,9 @@ class CustomerRelations::Contact < ApplicationRecord
self.errors.add(:email, I18n.t(:invalid, scope: 'valid_email.validations.email')) unless ValidateEmail.valid?(self.email)
end
def unique_email_for_group_hierarchy
return unless group
return unless email
def validate_root_group
return if group&.root?
duplicate_email_exists = CustomerRelations::Contact
.where(group_id: group.self_and_hierarchy.pluck(:id), email: email)
.where.not(id: id).exists?
self.errors.add(:email, _('contact with same email already exists in group hierarchy')) if duplicate_email_exists
self.errors.add(:base, _('contacts can only be added to root groups'))
end
end

View File

@ -6,7 +6,7 @@ class CustomerRelations::IssueContact < ApplicationRecord
belongs_to :issue, optional: false, inverse_of: :customer_relations_contacts
belongs_to :contact, optional: false, inverse_of: :issue_contacts
validate :contact_belongs_to_issue_group_or_ancestor
validate :contact_belongs_to_root_group
def self.find_contact_ids_by_emails(issue_id, emails)
raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK
@ -24,11 +24,11 @@ class CustomerRelations::IssueContact < ApplicationRecord
private
def contact_belongs_to_issue_group_or_ancestor
def contact_belongs_to_root_group
return unless contact&.group_id
return unless issue&.project&.namespace_id
return if issue.project.group&.self_and_ancestor_ids&.include?(contact.group_id)
return if issue.project.root_ancestor&.id == contact.group_id
errors.add(:base, _('The contact does not belong to the issue group or an ancestor'))
errors.add(:base, _("The contact does not belong to the issue group's root ancestor"))
end
end

View File

@ -19,9 +19,18 @@ class CustomerRelations::Organization < ApplicationRecord
validates :name, uniqueness: { case_sensitive: false, scope: [:group_id] }
validates :name, length: { maximum: 255 }
validates :description, length: { maximum: 1024 }
validate :validate_root_group
def self.find_by_name(group_id, name)
where(group: group_id)
.where('LOWER(name) = LOWER(?)', name)
end
private
def validate_root_group
return if group&.root?
self.errors.add(:base, _('organizations can only be added to root groups'))
end
end

View File

@ -15,6 +15,9 @@ module Integrations
ATLASSIAN_REFERRER_GITLAB_COM = { atlOrigin: 'eyJpIjoiY2QyZTJiZDRkNGZhNGZlMWI3NzRkNTBmZmVlNzNiZTkiLCJwIjoianN3LWdpdGxhYi1pbnQifQ' }.freeze
ATLASSIAN_REFERRER_SELF_MANAGED = { atlOrigin: 'eyJpIjoiYjM0MTA4MzUyYTYxNDVkY2IwMzVjOGQ3ZWQ3NzMwM2QiLCJwIjoianN3LWdpdGxhYlNNLWludCJ9' }.freeze
SECTION_TYPE_JIRA_TRIGGER = 'jira_trigger'
SECTION_TYPE_JIRA_ISSUES = 'jira_issues'
validates :url, public_url: true, presence: true, if: :activated?
validates :api_url, public_url: true, allow_blank: true
validates :username, presence: true, if: :activated?
@ -157,13 +160,31 @@ module Integrations
end
def sections
[
jira_issues_link_start = '<a href="%{url}">'.html_safe % { url: help_page_url('integration/jira/issues.html') }
sections = [
{
type: SECTION_TYPE_CONNECTION,
title: s_('Integrations|Connection details'),
description: help
},
{
type: SECTION_TYPE_JIRA_TRIGGER,
title: _('Trigger'),
description: s_('JiraService|When a Jira issue is mentioned in a commit or merge request, a remote link and comment (if enabled) will be created.')
}
].freeze
]
# Jira issues is currently only configurable on the project level.
if project_level?
sections.push({
type: SECTION_TYPE_JIRA_ISSUES,
title: _('Issues'),
description: s_('JiraService|Work on Jira issues without leaving GitLab. Add a Jira menu to access a read-only list of your Jira issues. %{jira_issues_link_start}Learn more.%{link_end}') % { jira_issues_link_start: jira_issues_link_start, link_end: '</a>'.html_safe }
})
end
sections
end
def web_url(path = nil, **params)

View File

@ -13,7 +13,7 @@ class IssuePolicy < IssuablePolicy
end
desc "User can read contacts belonging to the issue group"
condition(:can_read_crm_contacts, scope: :subject) { @user.can?(:read_crm_contact, @subject.project.group) }
condition(:can_read_crm_contacts, scope: :subject) { @user.can?(:read_crm_contact, @subject.project.root_ancestor) }
desc "Issue is confidential"
condition(:confidential, scope: :subject) { @subject.confidential? }

View File

@ -10,6 +10,11 @@ class IssueSidebarBasicEntity < IssuableSidebarBasicEntity
can?(current_user, :update_escalation_status, issue.project)
end
end
expose :show_crm_contacts do |issuable|
current_user&.can?(:read_crm_contact, issuable.project.root_ancestor) &&
CustomerRelations::Contact.exists_for_group?(issuable.project.root_ancestor)
end
end
IssueSidebarBasicEntity.prepend_mod_with('IssueSidebarBasicEntity')

View File

@ -65,7 +65,7 @@ module Ci
def check_access!(build)
unless can?(current_user, :update_build, build)
raise Gitlab::Access::AccessDeniedError
raise Gitlab::Access::AccessDeniedError, '403 Forbidden'
end
end

View File

@ -5,9 +5,8 @@ module Ci
include Gitlab::OptimisticLocking
def execute(pipeline)
unless can?(current_user, :update_pipeline, pipeline)
raise Gitlab::Access::AccessDeniedError
end
access_response = check_access(pipeline)
return access_response if access_response.error?
pipeline.ensure_scheduling_type!
@ -30,6 +29,18 @@ module Ci
Ci::ProcessPipelineService
.new(pipeline)
.execute
ServiceResponse.success
rescue Gitlab::Access::AccessDeniedError => e
ServiceResponse.error(message: e.message, http_status: :forbidden)
end
def check_access(pipeline)
if can?(current_user, :update_pipeline, pipeline)
ServiceResponse.success
else
ServiceResponse.error(message: '403 Forbidden', http_status: :forbidden)
end
end
private

View File

@ -52,7 +52,7 @@ module Issues
end
def add_by_email
contact_ids = ::CustomerRelations::Contact.find_ids_by_emails(project_group, emails(:add_emails))
contact_ids = ::CustomerRelations::Contact.find_ids_by_emails(project_group.root_ancestor, emails(:add_emails))
add_by_id(contact_ids)
end

View File

@ -42,7 +42,7 @@
= render_if_exists 'groups/personal_access_token_expiration_policy', f: f, group: @group
= render 'groups/settings/membership', f: f, group: @group
- if crm_feature_flag_enabled?(@group)
- if crm_feature_available?(@group)
%h5= _('Customer relations')
.form-group.gl-mb-3
= f.gitlab_ui_checkbox_component :crm_enabled,

View File

@ -1,8 +1,8 @@
- if @errors.present?
.gl-alert.gl-alert-danger.gl-mb-5
.gl-alert-container
= sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
.gl-alert-content
.gl-alert-body
- @errors.each do |error|
= error
= render 'shared/global_alert',
variant: :danger,
dismissible: false,
alert_class: 'gl-mb-5' do
.gl-alert-body
- @errors.each do |error|
= error

View File

@ -41,7 +41,7 @@
.block{ class: 'gl-pt-0! gl-collapse-empty', data: { qa_selector: 'iteration_container', testid: 'iteration_container' } }<
= render_if_exists 'shared/issuable/iteration_select', can_edit: can_edit_issuable.to_s, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type
- if @show_crm_contacts
- if issuable_sidebar[:show_crm_contacts]
.block.contact
#js-issue-crm-contacts{ data: { issue_id: issuable_sidebar[:id] } }

View File

@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/348786
milestone: '14.6'
type: development
group: group::pipeline execution
default_enabled: false
default_enabled: true

View File

@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/354955
milestone: '14.9'
type: development
group: group::pipeline insights
default_enabled: false
default_enabled: true

View File

@ -0,0 +1,8 @@
---
name: vulnerability_reads_table
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/76220
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/348151
milestone: '14.9'
type: development
group: group::threat insights
default_enabled: false

View File

@ -6,15 +6,18 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Customer relations management (CRM) **(FREE)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/2256) in GitLab 14.6 [with a flag](../../administration/feature_flags.md) named `customer_relations`. Disabled by default.
FLAG:
On self-managed GitLab, by default this feature is not available. To make it available, ask an administrator to [enable the feature flag](../../administration/feature_flags.md) named `customer_relations`.
On GitLab.com, this feature is not available.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/2256) in GitLab 14.6 [with a flag](../../administration/feature_flags.md) named `customer_relations`. Disabled by default.
> - In GitLab 14.8 and later, you can [create contacts and organizations only in root groups](https://gitlab.com/gitlab-org/gitlab/-/issues/350634).
With customer relations management (CRM) you can create a record of contacts
(individuals) and organizations (companies) and relate them to issues.
Contacts and organizations can only be created for root groups.
You can use contacts and organizations to tie work to customers for billing and reporting purposes.
To read more about what is planned for the future, see [issue 2256](https://gitlab.com/gitlab-org/gitlab/-/issues/2256).

View File

@ -130,7 +130,7 @@ module.exports = (path, options = {}) => {
'^.+\\.(md|zip|png)$': 'jest-raw-loader',
},
transformIgnorePatterns: [
'node_modules/(?!(@gitlab/ui|@gitlab/favicon-overlay|bootstrap-vue|three|monaco-editor|monaco-yaml|fast-mersenne-twister|prosemirror-markdown|dateformat)/)',
'node_modules/(?!(@gitlab/ui|@gitlab/favicon-overlay|bootstrap-vue|three|monaco-editor|monaco-yaml|fast-mersenne-twister|prosemirror-markdown|dateformat|lowlight|fault)/)',
],
timers: 'fake',
testEnvironment: '<rootDir>/spec/frontend/environment.js',

View File

@ -223,9 +223,13 @@ module API
post ':id/pipelines/:pipeline_id/retry', feature_category: :continuous_integration do
authorize! :update_pipeline, pipeline
pipeline.retry_failed(current_user)
response = pipeline.retry_failed(current_user)
present pipeline, with: Entities::Ci::Pipeline
if response.success?
present pipeline, with: Entities::Ci::Pipeline
else
render_api_error!(response.errors.join(', '), response.http_status)
end
end
desc 'Cancel all builds in the pipeline' do

View File

@ -291,7 +291,7 @@ module Gitlab
types Issue
condition do
current_user.can?(:set_issue_crm_contacts, quick_action_target) &&
CustomerRelations::Contact.exists_for_group?(quick_action_target.project.group)
CustomerRelations::Contact.exists_for_group?(quick_action_target.project.root_ancestor)
end
execution_message do
_('One or more contacts were successfully added.')
@ -306,7 +306,7 @@ module Gitlab
types Issue
condition do
current_user.can?(:set_issue_crm_contacts, quick_action_target) &&
CustomerRelations::Contact.exists_for_group?(quick_action_target.project.group)
CustomerRelations::Contact.exists_for_group?(quick_action_target.project.root_ancestor)
end
execution_message do
_('One or more contacts were successfully removed.')

View File

@ -24,6 +24,8 @@ module Sidebars
override :render?
def render?
return false unless context.group.root?
can_read_contact? || can_read_organization?
end

View File

@ -847,6 +847,9 @@ msgstr ""
msgid "%{name}, confirm your email address now!"
msgstr ""
msgid "%{name}: %{resultsString}"
msgstr ""
msgid "%{no_of_days} day"
msgid_plural "%{no_of_days} days"
msgstr[0] ""
@ -21059,6 +21062,9 @@ msgstr ""
msgid "JiraService|Work on Jira issues without leaving GitLab. Add a Jira menu to access a read-only list of your Jira issues."
msgstr ""
msgid "JiraService|Work on Jira issues without leaving GitLab. Add a Jira menu to access a read-only list of your Jira issues. %{jira_issues_link_start}Learn more.%{link_end}"
msgstr ""
msgid "JiraService|You must configure Jira before enabling this integration. %{jira_doc_link_start}Learn more.%{link_end}"
msgstr ""
@ -30941,6 +30947,9 @@ msgstr ""
msgid "Reports|Filename"
msgstr ""
msgid "Reports|Full report"
msgstr ""
msgid "Reports|Head report parsing error:"
msgstr ""
@ -30983,9 +30992,15 @@ msgstr ""
msgid "Reports|Test summary failed loading results"
msgstr ""
msgid "Reports|Test summary failed to load results"
msgstr ""
msgid "Reports|Test summary results are being parsed"
msgstr ""
msgid "Reports|Test summary results are loading"
msgstr ""
msgid "Reports|Tool"
msgstr ""
@ -36665,7 +36680,7 @@ msgstr ""
msgid "The connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
msgstr ""
msgid "The contact does not belong to the issue group or an ancestor"
msgid "The contact does not belong to the issue group's root ancestor"
msgstr ""
msgid "The content editor may change the markdown formatting style of the document, which may not match your original markdown style."
@ -43453,7 +43468,7 @@ msgstr ""
msgid "compliance violation has already been recorded"
msgstr ""
msgid "contact with same email already exists in group hierarchy"
msgid "contacts can only be added to root groups"
msgstr ""
msgid "container registry images"
@ -44363,6 +44378,9 @@ msgstr ""
msgid "or"
msgstr ""
msgid "organizations can only be added to root groups"
msgstr ""
msgid "other card matches"
msgstr ""

View File

@ -141,7 +141,7 @@
"jszip-utils": "^0.0.2",
"katex": "^0.13.2",
"lodash": "^4.17.20",
"lowlight": "^1.20.0",
"lowlight": "^2.5.0",
"marked": "^0.3.12",
"mathjax": "3",
"mermaid": "^8.13.10",

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module QA
RSpec.describe 'Manage', :requires_admin do
RSpec.describe 'Manage', :reliable, :requires_admin do
describe 'Gitlab migration' do
let(:import_wait_duration) { { max_duration: 300, sleep_interval: 2 } }
let(:admin_api_client) { Runtime::API::Client.as_admin }

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module QA
RSpec.describe 'Manage', :github, :requires_admin do
RSpec.describe 'Manage', :reliable, :github, :requires_admin do
describe 'Project import' do
let(:github_repo) { 'gitlab-qa-github/import-test' }
let(:api_client) { Runtime::API::Client.as_admin }

View File

@ -932,6 +932,33 @@ RSpec.describe Projects::PipelinesController do
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when access denied' do
it 'returns an error' do
sign_in(create(:user))
post_retry
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when service returns an error' do
before do
service_response = ServiceResponse.error(message: 'some error', http_status: 404)
allow_next_instance_of(::Ci::RetryPipelineService) do |service|
allow(service).to receive(:check_access).and_return(service_response)
end
end
it 'does not retry' do
post_retry
expect(response).to have_gitlab_http_status(:not_found)
expect(response.body).to include('some error')
expect(::Ci::RetryPipelineWorker).not_to have_received(:perform_async).with(pipeline.id, user.id)
end
end
end
describe 'POST cancel.json' do

View File

@ -21,7 +21,7 @@ FactoryBot.define do
trait :for_issue do
issue { raise ArgumentError, '`issue` is manadatory' }
contact { association(:contact, group: issue.project.group) }
contact { association(:contact, group: issue.project.root_ancestor) }
end
end
end

View File

@ -60,6 +60,18 @@ RSpec.describe 'Group navbar' do
it_behaves_like 'verified navigation bar'
end
context 'when customer_relations feature and flag is enabled but subgroup' do
let(:group) { create(:group, :crm_enabled, parent: create(:group)) }
before do
stub_feature_flags(customer_relations: true)
visit group_path(group)
end
it_behaves_like 'verified navigation bar'
end
context 'when dependency proxy is available' do
before do
stub_config(dependency_proxy: { enabled: true })

View File

@ -435,6 +435,29 @@ describe('IntegrationForm', () => {
});
},
);
describe('when IntegrationSectionConnection emits `request-jira-issue-types` event', () => {
beforeEach(() => {
jest.spyOn(document, 'querySelector').mockReturnValue(document.createElement('form'));
createComponent({
provide: {
glFeatures: { integrationFormSections: true },
},
customStateProps: {
sections: [mockSectionConnection],
testPath: '/test',
},
mountFn: mountExtended,
});
findConnectionSectionComponent().vm.$emit('request-jira-issue-types');
});
it('dispatches `requestJiraIssueTypes` action', () => {
expect(dispatch).toHaveBeenCalledWith('requestJiraIssueTypes', expect.any(FormData));
});
});
});
describe('ActiveCheckbox', () => {

View File

@ -0,0 +1,34 @@
import { shallowMount } from '@vue/test-utils';
import IntegrationSectionJiraIssue from '~/integrations/edit/components/sections/jira_issues.vue';
import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue';
import { createStore } from '~/integrations/edit/store';
import { mockIntegrationProps } from '../../mock_data';
describe('IntegrationSectionJiraIssue', () => {
let wrapper;
const createComponent = () => {
const store = createStore({
customState: { ...mockIntegrationProps },
});
wrapper = shallowMount(IntegrationSectionJiraIssue, {
store,
});
};
afterEach(() => {
wrapper.destroy();
});
const findJiraIssuesFields = () => wrapper.findComponent(JiraIssuesFields);
describe('template', () => {
it('renders JiraIssuesFields', () => {
createComponent();
expect(findJiraIssuesFields().exists()).toBe(true);
});
});
});

View File

@ -0,0 +1,34 @@
import { shallowMount } from '@vue/test-utils';
import IntegrationSectionJiraTrigger from '~/integrations/edit/components/sections/jira_trigger.vue';
import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue';
import { createStore } from '~/integrations/edit/store';
import { mockIntegrationProps } from '../../mock_data';
describe('IntegrationSectionJiraTrigger', () => {
let wrapper;
const createComponent = () => {
const store = createStore({
customState: { ...mockIntegrationProps },
});
wrapper = shallowMount(IntegrationSectionJiraTrigger, {
store,
});
};
afterEach(() => {
wrapper.destroy();
});
const findJiraTriggerFields = () => wrapper.findComponent(JiraTriggerFields);
describe('template', () => {
it('renders JiraTriggerFields', () => {
createComponent();
expect(findJiraTriggerFields().exists()).toBe(true);
});
});
});

View File

@ -1,5 +1,7 @@
import { GlModal, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
import HeaderComponent from '~/pipelines/components/header_component.vue';
import cancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql';
import deletePipelineMutation from '~/pipelines/graphql/mutations/delete_pipeline.mutation.graphql';
@ -17,6 +19,7 @@ import {
describe('Pipeline details header', () => {
let wrapper;
let glModalDirective;
let mutate = jest.fn();
const findDeleteModal = () => wrapper.find(GlModal);
const findRetryButton = () => wrapper.find('[data-testid="retryPipeline"]');
@ -44,7 +47,7 @@ describe('Pipeline details header', () => {
startPolling: jest.fn(),
},
},
mutate: jest.fn(),
mutate,
};
return shallowMount(HeaderComponent, {
@ -120,6 +123,26 @@ describe('Pipeline details header', () => {
});
});
describe('Retry action failed', () => {
beforeEach(() => {
mutate = jest.fn().mockRejectedValue('error');
wrapper = createComponent(mockCancelledPipelineHeader);
});
it('retry button loading state should reset on error', async () => {
findRetryButton().vm.$emit('click');
await nextTick();
expect(findRetryButton().props('loading')).toBe(true);
await waitForPromises();
expect(findRetryButton().props('loading')).toBe(false);
});
});
describe('Cancel action', () => {
beforeEach(() => {
wrapper = createComponent(mockRunningPipelineHeader);

View File

@ -0,0 +1,149 @@
import { GlButton } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import testReportExtension from '~/vue_merge_request_widget/extensions/test_report';
import { i18n } from '~/vue_merge_request_widget/extensions/test_report/constants';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { trimText } from 'helpers/text_helper';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container';
import { registerExtension } from '~/vue_merge_request_widget/components/extensions';
import httpStatusCodes from '~/lib/utils/http_status';
import { failedReport } from '../../../reports/mock_data/mock_data';
import mixedResultsTestReports from '../../../reports/mock_data/new_and_fixed_failures_report.json';
import newErrorsTestReports from '../../../reports/mock_data/new_errors_report.json';
import newFailedTestReports from '../../../reports/mock_data/new_failures_report.json';
import successTestReports from '../../../reports/mock_data/no_failures_report.json';
import resolvedFailures from '../../../reports/mock_data/resolved_failures.json';
const reportWithParsingErrors = failedReport;
reportWithParsingErrors.suites[0].suite_errors = {
head: 'JUnit XML parsing failed: 2:24: FATAL: attributes construct error',
base: 'JUnit data parsing failed: string not matched',
};
describe('Test report extension', () => {
let wrapper;
let mock;
registerExtension(testReportExtension);
const endpoint = '/root/repo/-/merge_requests/4/test_reports.json';
const mockApi = (statusCode, data = mixedResultsTestReports) => {
mock.onGet(endpoint).reply(statusCode, data);
};
const findToggleCollapsedButton = () => wrapper.findByTestId('toggle-button');
const findTertiaryButton = () => wrapper.find(GlButton);
const findAllExtensionListItems = () => wrapper.findAllByTestId('extension-list-item');
const createComponent = () => {
wrapper = mountExtended(extensionsContainer, {
propsData: {
mr: {
testResultsPath: endpoint,
headBlobPath: 'head/blob/path',
pipeline: { path: 'pipeline/path' },
},
},
});
};
const createExpandedWidgetWithData = async (data = mixedResultsTestReports) => {
mockApi(httpStatusCodes.OK, data);
createComponent();
await waitForPromises();
findToggleCollapsedButton().trigger('click');
await waitForPromises();
};
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
describe('summary', () => {
it('displays loading text', () => {
mockApi(httpStatusCodes.OK);
createComponent();
expect(wrapper.text()).toContain(i18n.loading);
});
it('displays failed loading text', async () => {
mockApi(httpStatusCodes.INTERNAL_SERVER_ERROR);
createComponent();
await waitForPromises();
expect(wrapper.text()).toContain(i18n.error);
});
it.each`
description | mockData | expectedResult
${'mixed test results'} | ${mixedResultsTestReports} | ${'Test summary: 2 failed and 2 fixed test results, 11 total tests'}
${'unchanged test results'} | ${successTestReports} | ${'Test summary: no changed test results, 11 total tests'}
${'tests with errors'} | ${newErrorsTestReports} | ${'Test summary: 2 errors, 11 total tests'}
${'failed test results'} | ${newFailedTestReports} | ${'Test summary: 2 failed, 11 total tests'}
${'resolved failures'} | ${resolvedFailures} | ${'Test summary: 4 fixed test results, 11 total tests'}
`('displays summary text for $description', async ({ mockData, expectedResult }) => {
mockApi(httpStatusCodes.OK, mockData);
createComponent();
await waitForPromises();
expect(wrapper.text()).toContain(expectedResult);
});
it('displays a link to the full report', async () => {
mockApi(httpStatusCodes.OK);
createComponent();
await waitForPromises();
expect(findTertiaryButton().text()).toBe('Full report');
expect(findTertiaryButton().attributes('href')).toBe('pipeline/path/test_report');
});
it('shows an error when a suite has a parsing error', async () => {
mockApi(httpStatusCodes.OK, reportWithParsingErrors);
createComponent();
await waitForPromises();
expect(wrapper.text()).toContain(i18n.error);
});
});
describe('expanded data', () => {
it('displays summary for each suite', async () => {
await createExpandedWidgetWithData();
expect(trimText(findAllExtensionListItems().at(0).text())).toBe(
'rspec:pg: 1 failed and 2 fixed test results, 8 total tests',
);
expect(trimText(findAllExtensionListItems().at(1).text())).toBe(
'java ant: 1 failed, 3 total tests',
);
});
it('displays suite parsing errors', async () => {
await createExpandedWidgetWithData(reportWithParsingErrors);
const suiteText = trimText(findAllExtensionListItems().at(0).text());
expect(suiteText).toContain(
'Head report parsing error: JUnit XML parsing failed: 2:24: FATAL: attributes construct error',
);
expect(suiteText).toContain(
'Base report parsing error: JUnit data parsing failed: string not matched',
);
});
});
});

View File

@ -1025,7 +1025,7 @@ describe('MrWidgetOptions', () => {
it('captures sentry error and displays error when poll has failed', () => {
expect(captureException).toHaveBeenCalledTimes(1);
expect(captureException).toHaveBeenCalledWith(new Error('Fetch error'));
expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('error');
expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed');
});
});
});
@ -1036,7 +1036,7 @@ describe('MrWidgetOptions', () => {
const itHandlesTheException = () => {
expect(captureException).toHaveBeenCalledTimes(1);
expect(captureException).toHaveBeenCalledWith(new Error('Fetch error'));
expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('error');
expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed');
};
beforeEach(() => {

View File

@ -3,23 +3,45 @@
require 'spec_helper'
RSpec.describe Groups::CrmSettingsHelper do
let_it_be(:group) { create(:group) }
let_it_be(:root_group) { create(:group) }
describe '#crm_feature_flag_enabled?' do
describe '#crm_feature_available?' do
subject do
helper.crm_feature_flag_enabled?(group)
helper.crm_feature_available?(group)
end
context 'when feature flag is enabled' do
it { is_expected.to be_truthy }
end
context 'in root group' do
let(:group) { root_group }
context 'when feature flag is disabled' do
before do
stub_feature_flags(customer_relations: false)
context 'when feature flag is enabled' do
it { is_expected.to be_truthy }
end
it { is_expected.to be_falsy }
context 'when feature flag is disabled' do
before do
stub_feature_flags(customer_relations: false)
end
it { is_expected.to be_falsy }
end
end
context 'in subgroup' do
let_it_be(:subgroup) { create(:group, parent: root_group) }
let(:group) { subgroup }
context 'when feature flag is enabled' do
it { is_expected.to be_truthy }
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(customer_relations: false)
end
it { is_expected.to be_falsy }
end
end
end
end

View File

@ -610,6 +610,7 @@ project:
- sync_events
- secure_files
- security_trainings
- vulnerability_reads
award_emoji:
- awardable
- user

View File

@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe CustomerRelations::Contact, type: :model do
let_it_be(:group) { create(:group) }
describe 'associations' do
it { is_expected.to belong_to(:group) }
it { is_expected.to belong_to(:organization).optional }
@ -23,6 +25,8 @@ RSpec.describe CustomerRelations::Contact, type: :model do
it { is_expected.to validate_length_of(:email).is_at_most(255) }
it { is_expected.to validate_length_of(:description).is_at_most(1024) }
it { is_expected.to validate_uniqueness_of(:email).scoped_to(:group_id) }
it_behaves_like 'an object with RFC3696 compliant email-formatted attributes', :email
end
@ -38,33 +42,15 @@ RSpec.describe CustomerRelations::Contact, type: :model do
it { expect(described_class.reference_postfix).to eq(']') }
end
describe '#unique_email_for_group_hierarchy' do
let_it_be(:parent) { create(:group) }
let_it_be(:group) { create(:group, parent: parent) }
let_it_be(:subgroup) { create(:group, parent: group) }
let_it_be(:existing_contact) { create(:contact, group: group) }
context 'with unique email for group hierarchy' do
describe '#root_group' do
context 'when root group' do
subject { build(:contact, group: group) }
it { is_expected.to be_valid }
end
context 'with duplicate email in group' do
subject { build(:contact, email: existing_contact.email, group: group) }
it { is_expected.to be_invalid }
end
context 'with duplicate email in parent group' do
subject { build(:contact, email: existing_contact.email, group: subgroup) }
it { is_expected.to be_invalid }
end
context 'with duplicate email in subgroup' do
subject { build(:contact, email: existing_contact.email, group: parent) }
context 'when subgroup' do
subject { build(:contact, group: create(:group, parent: group)) }
it { is_expected.to be_invalid }
end
@ -82,7 +68,6 @@ RSpec.describe CustomerRelations::Contact, type: :model do
end
describe '#self.find_ids_by_emails' do
let_it_be(:group) { create(:group) }
let_it_be(:group_contacts) { create_list(:contact, 2, group: group) }
let_it_be(:other_contacts) { create_list(:contact, 2) }
@ -92,13 +77,6 @@ RSpec.describe CustomerRelations::Contact, type: :model do
expect(contact_ids).to match_array(group_contacts.pluck(:id))
end
it 'returns ids of contacts from parent group' do
subgroup = create(:group, parent: group)
contact_ids = described_class.find_ids_by_emails(subgroup, group_contacts.pluck(:email))
expect(contact_ids).to match_array(group_contacts.pluck(:id))
end
it 'does not return ids of contacts from other groups' do
contact_ids = described_class.find_ids_by_emails(group, other_contacts.pluck(:email))
@ -112,28 +90,17 @@ RSpec.describe CustomerRelations::Contact, type: :model do
end
describe '#self.exists_for_group?' do
let(:group) { create(:group) }
let(:subgroup) { create(:group, parent: group) }
context 'with no contacts in group or parent' do
context 'with no contacts in group' do
it 'returns false' do
expect(described_class.exists_for_group?(subgroup)).to be_falsey
expect(described_class.exists_for_group?(group)).to be_falsey
end
end
context 'with contacts in group' do
it 'returns true' do
create(:contact, group: subgroup)
expect(described_class.exists_for_group?(subgroup)).to be_truthy
end
end
context 'with contacts in parent' do
it 'returns true' do
create(:contact, group: group)
expect(described_class.exists_for_group?(subgroup)).to be_truthy
expect(described_class.exists_for_group?(group)).to be_truthy
end
end
end

View File

@ -6,7 +6,8 @@ RSpec.describe CustomerRelations::IssueContact do
let_it_be(:issue_contact, reload: true) { create(:issue_customer_relations_contact) }
let_it_be(:group) { create(:group) }
let_it_be(:subgroup) { create(:group, parent: group) }
let_it_be(:project) { create(:project, group: subgroup) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:subgroup_project) { create(:project, group: subgroup) }
let_it_be(:issue) { create(:issue, project: project) }
subject { issue_contact }
@ -27,33 +28,36 @@ RSpec.describe CustomerRelations::IssueContact do
let(:for_issue) { build(:issue_customer_relations_contact, :for_issue, issue: issue) }
let(:for_contact) { build(:issue_customer_relations_contact, :for_contact, contact: contact) }
it 'uses objects from the same group', :aggregate_failures do
expect(stubbed.contact.group).to eq(stubbed.issue.project.group)
expect(built.contact.group).to eq(built.issue.project.group)
expect(created.contact.group).to eq(created.issue.project.group)
context 'for root groups' do
it 'uses objects from the same group', :aggregate_failures do
expect(stubbed.contact.group).to eq(stubbed.issue.project.group)
expect(built.contact.group).to eq(built.issue.project.group)
expect(created.contact.group).to eq(created.issue.project.group)
end
end
it 'builds using the same group', :aggregate_failures do
expect(for_issue.contact.group).to eq(subgroup)
expect(for_contact.issue.project.group).to eq(group)
context 'for subgroups' do
it 'builds using the root ancestor' do
expect(for_issue.contact.group).to eq(group)
end
end
end
describe 'validation' do
it 'fails when the contact group does not belong to the issue group or ancestors' do
it 'fails when the contact group is unrelated to the issue group' do
built = build(:issue_customer_relations_contact, issue: create(:issue), contact: create(:contact))
expect(built).not_to be_valid
end
it 'succeeds when the contact group is the same as the issue group' do
built = build(:issue_customer_relations_contact, issue: create(:issue, project: project), contact: create(:contact, group: subgroup))
it 'succeeds when the contact belongs to a root group and is the same as the issue group' do
built = build(:issue_customer_relations_contact, issue: create(:issue, project: project), contact: create(:contact, group: group))
expect(built).to be_valid
end
it 'succeeds when the contact group is an ancestor of the issue group' do
built = build(:issue_customer_relations_contact, issue: create(:issue, project: project), contact: create(:contact, group: group))
it 'succeeds when the contact belongs to a root group and it is an ancestor of the issue group' do
built = build(:issue_customer_relations_contact, issue: create(:issue, project: subgroup_project), contact: create(:contact, group: group))
expect(built).to be_valid
end

View File

@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe CustomerRelations::Organization, type: :model do
let_it_be(:group) { create(:group) }
describe 'associations' do
it { is_expected.to belong_to(:group).with_foreign_key('group_id') }
end
@ -17,6 +19,20 @@ RSpec.describe CustomerRelations::Organization, type: :model do
it { is_expected.to validate_length_of(:description).is_at_most(1024) }
end
describe '#root_group' do
context 'when root group' do
subject { build(:organization, group: group) }
it { is_expected.to be_valid }
end
context 'when subgroup' do
subject { build(:organization, group: create(:group, parent: group)) }
it { is_expected.to be_invalid }
end
end
describe '#name' do
it 'strips name' do
organization = described_class.new(name: ' GitLab ')
@ -27,7 +43,6 @@ RSpec.describe CustomerRelations::Organization, type: :model do
end
describe '#find_by_name' do
let!(:group) { create(:group) }
let!(:organiztion1) { create(:organization, group: group, name: 'Test') }
let!(:organiztion2) { create(:organization, group: create(:group), name: 'Test') }

View File

@ -2950,7 +2950,14 @@ RSpec.describe Group do
expect(group.crm_enabled?).to be_truthy
end
it 'returns true where crm_settings.state is enabled for subgroup' do
subgroup = create(:group, :crm_enabled, parent: group)
expect(subgroup.crm_enabled?).to be_truthy
end
end
describe '.get_ids_by_ids_or_paths' do
let(:group_path) { 'group_path' }
let!(:group) { create(:group, path: group_path) }

View File

@ -109,6 +109,32 @@ RSpec.describe Integrations::Jira do
end
end
describe '#sections' do
let(:integration) { create(:jira_integration) }
subject(:sections) { integration.sections.map { |s| s[:type] } }
context 'when project_level? is true' do
before do
allow(integration).to receive(:project_level?).and_return(true)
end
it 'includes SECTION_TYPE_JIRA_ISSUES' do
expect(sections).to include(described_class::SECTION_TYPE_JIRA_ISSUES)
end
end
context 'when project_level? is false' do
before do
allow(integration).to receive(:project_level?).and_return(false)
end
it 'does not include SECTION_TYPE_JIRA_ISSUES' do
expect(sections).not_to include(described_class::SECTION_TYPE_JIRA_ISSUES)
end
end
end
describe '.reference_pattern' do
using RSpec::Parameterized::TableSyntax

View File

@ -1167,7 +1167,6 @@ RSpec.describe Issue do
end
describe '#check_for_spam?' do
using RSpec::Parameterized::TableSyntax
let_it_be(:support_bot) { ::User.support_bot }
where(:support_bot?, :visibility_level, :confidential, :new_attributes, :check_for_spam?) do

View File

@ -396,4 +396,36 @@ RSpec.describe IssuePolicy do
expect(policies).to be_allowed(:read_issue_iid)
end
end
describe 'set_issue_crm_contacts' do
let(:user) { create(:user) }
let(:subgroup) { create(:group, :crm_enabled, parent: create(:group, :crm_enabled)) }
let(:project) { create(:project, group: subgroup) }
let(:issue) { create(:issue, project: project) }
let(:policies) { described_class.new(user, issue) }
context 'when project reporter' do
it 'is disallowed' do
project.add_reporter(user)
expect(policies).to be_disallowed(:set_issue_crm_contacts)
end
end
context 'when subgroup reporter' do
it 'is allowed' do
subgroup.add_reporter(user)
expect(policies).to be_disallowed(:set_issue_crm_contacts)
end
end
context 'when root group reporter' do
it 'is allowed' do
subgroup.parent.add_reporter(user)
expect(policies).to be_allowed(:set_issue_crm_contacts)
end
end
end
end

View File

@ -1075,6 +1075,23 @@ RSpec.describe API::Ci::Pipelines do
expect(json_response['id']).to be nil
end
end
context 'handles errors' do
before do
service_response = ServiceResponse.error(http_status: 403, message: 'hello world')
allow_next_instance_of(::Ci::RetryPipelineService) do |service|
allow(service).to receive(:check_access).and_return(service_response)
end
end
it 'returns error' do
post api("/projects/#{project.id}/pipelines/#{pipeline.id}/retry", user)
expect(response).to have_gitlab_http_status(:forbidden)
expect(json_response['message']).to eq 'hello world'
expect(json_response['id']).to be nil
end
end
end
describe 'POST /projects/:id/pipelines/:pipeline_id/cancel' do

View File

@ -9,12 +9,10 @@ RSpec.describe 'Setting issues crm contacts' do
let_it_be(:group) { create(:group, :crm_enabled) }
let_it_be(:subgroup) { create(:group, :crm_enabled, parent: group) }
let_it_be(:project) { create(:project, group: subgroup) }
let_it_be(:group_contacts) { create_list(:contact, 4, group: group) }
let_it_be(:subgroup_contacts) { create_list(:contact, 4, group: subgroup) }
let_it_be(:contacts) { create_list(:contact, 4, group: group) }
let(:issue) { create(:issue, project: project) }
let(:operation_mode) { Types::MutationOperationModeEnum.default_mode }
let(:contacts) { subgroup_contacts }
let(:initial_contacts) { contacts[0..1] }
let(:mutation_contacts) { contacts[1..2] }
let(:contact_ids) { contact_global_ids(mutation_contacts) }
@ -116,15 +114,7 @@ RSpec.describe 'Setting issues crm contacts' do
end
end
context 'with issue group contacts' do
let(:contacts) { subgroup_contacts }
it_behaves_like 'successful mutation'
end
context 'with issue ancestor group contacts' do
it_behaves_like 'successful mutation'
end
it_behaves_like 'successful mutation'
context 'when the contact does not exist' do
let(:contact_ids) { ["gid://gitlab/CustomerRelations::Contact/#{non_existing_record_id}"] }

View File

@ -49,6 +49,12 @@ RSpec.describe Groups::Crm::ContactsController do
it_behaves_like 'response with 404 status'
end
context 'when subgroup' do
let(:group) { create(:group, :private, :crm_enabled, parent: create(:group)) }
it_behaves_like 'response with 404 status'
end
end
context 'with unauthorized user' do

View File

@ -49,6 +49,12 @@ RSpec.describe Groups::Crm::OrganizationsController do
it_behaves_like 'response with 404 status'
end
context 'when subgroup' do
let(:group) { create(:group, :private, :crm_enabled, parent: create(:group)) }
it_behaves_like 'response with 404 status'
end
end
context 'with unauthorized user' do

View File

@ -3,9 +3,10 @@
require 'spec_helper'
RSpec.describe IssueSidebarBasicEntity do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:group) { create(:group, :crm_enabled) }
let_it_be(:project) { create(:project, :repository, group: group) }
let_it_be(:user) { create(:user, developer_projects: [project]) }
let_it_be(:issue) { create(:issue, project: project, assignees: [user]) }
let_it_be_with_reload(:issue) { create(:issue, project: project, assignees: [user]) }
let(:serializer) { IssueSerializer.new(current_user: user, project: project) }
@ -71,4 +72,27 @@ RSpec.describe IssueSidebarBasicEntity do
end
end
end
describe 'show_crm_contacts' do
using RSpec::Parameterized::TableSyntax
where(:is_reporter, :contacts_exist_for_group, :expected) do
false | false | false
false | true | false
true | false | false
true | true | true
end
with_them do
it 'sets proper boolean value for show_crm_contacts' do
allow(CustomerRelations::Contact).to receive(:exists_for_group?).with(group).and_return(contacts_exist_for_group)
if is_reporter
project.root_ancestor.add_reporter(user)
end
expect(entity[:show_crm_contacts]).to be(expected)
end
end
end
end

View File

@ -137,7 +137,7 @@ RSpec.describe Ci::RetryPipelineService, '#execute' do
end
end
context 'when the last stage was skipepd' do
context 'when the last stage was skipped' do
before do
create_build('build 1', :success, 0)
create_build('test 2', :failed, 1)
@ -336,12 +336,32 @@ RSpec.describe Ci::RetryPipelineService, '#execute' do
expect(pipeline.reload).to be_running
end
end
context 'when user is not allowed to retry build' do
before do
build = create(:ci_build, pipeline: pipeline, status: :failed)
allow_next_instance_of(Ci::RetryBuildService) do |service|
allow(service).to receive(:can?).with(user, :update_build, build).and_return(false)
end
end
it 'returns an error' do
response = service.execute(pipeline)
expect(response.http_status).to eq(:forbidden)
expect(response.errors).to include('403 Forbidden')
expect(pipeline.reload).not_to be_running
end
end
end
context 'when user is not allowed to retry pipeline' do
it 'raises an error' do
expect { service.execute(pipeline) }
.to raise_error Gitlab::Access::AccessDeniedError
it 'returns an error' do
response = service.execute(pipeline)
expect(response.http_status).to eq(:forbidden)
expect(response.errors).to include('403 Forbidden')
expect(pipeline.reload).not_to be_running
end
end
@ -359,9 +379,12 @@ RSpec.describe Ci::RetryPipelineService, '#execute' do
create_build('verify', :canceled, 1)
end
it 'raises an error' do
expect { service.execute(pipeline) }
.to raise_error Gitlab::Access::AccessDeniedError
it 'returns an error' do
response = service.execute(pipeline)
expect(response.http_status).to eq(:forbidden)
expect(response.errors).to include('403 Forbidden')
expect(pipeline.reload).not_to be_running
end
end
@ -372,9 +395,12 @@ RSpec.describe Ci::RetryPipelineService, '#execute' do
create_build('verify', :canceled, 2)
end
it 'raises an error' do
expect { service.execute(pipeline) }
.to raise_error Gitlab::Access::AccessDeniedError
it 'returns an error' do
response = service.execute(pipeline)
expect(response.http_status).to eq(:forbidden)
expect(response.errors).to include('403 Forbidden')
expect(pipeline.reload).not_to be_running
end
end
end

View File

@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe Issues::SetCrmContactsService do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :crm_enabled) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:project) { create(:project, group: create(:group, parent: group)) }
let_it_be(:contacts) { create_list(:contact, 4, group: group) }
let_it_be(:issue, reload: true) { create(:issue, project: project) }
let_it_be(:issue_contact_1) do

View File

@ -203,7 +203,7 @@ RSpec.shared_context 'group navbar structure' do
nav_sub_items: []
},
{
nav_item: _('Group information'),
nav_item: group.root? ? _('Group information') : _('Subgroup information'),
nav_sub_items: [
_('Activity'),
_('Labels'),

View File

@ -11,7 +11,7 @@ RSpec.describe 'shared/issuable/_sidebar.html.haml' do
end
context 'project in a group' do
let_it_be(:group) { create(:group) }
let_it_be(:group) { create(:group, :crm_enabled) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:incident) { create(:incident, project: project) }
@ -35,5 +35,34 @@ RSpec.describe 'shared/issuable/_sidebar.html.haml' do
expect(rendered).not_to have_css('[data-testid="escalation_status_container"]')
end
end
context 'crm contacts widget' do
let(:issuable) { issue }
context 'without permission' do
it 'is expected not to be shown' do
create(:contact, group: group)
expect(rendered).not_to have_css('#js-issue-crm-contacts')
end
end
context 'without contacts' do
it 'is expected not to be shown' do
group.add_developer(user)
expect(rendered).not_to have_css('#js-issue-crm-contacts')
end
end
context 'with permission and contacts' do
it 'is expected to be shown' do
create(:contact, group: group)
group.add_developer(user)
expect(rendered).to have_css('#js-issue-crm-contacts')
end
end
end
end
end

View File

@ -1896,6 +1896,13 @@
dependencies:
"@types/node" "*"
"@types/hast@^2.0.0":
version "2.3.4"
resolved "https://registry.yarnpkg.com/@types/hast/-/hast-2.3.4.tgz#8aa5ef92c117d20d974a82bdfb6a648b08c0bafc"
integrity sha512-wLEm0QvaoawEDoTRwzTXp4b4jpwiJDvR5KMnFnVodm3scufTlBOWRD6N1OBf9TZMhjlNsSfcO5V+7AF4+Vy+9g==
dependencies:
"@types/unist" "*"
"@types/http-proxy@^1.17.8":
version "1.17.8"
resolved "https://registry.yarnpkg.com/@types/http-proxy/-/http-proxy-1.17.8.tgz#968c66903e7e42b483608030ee85800f22d03f55"
@ -2119,6 +2126,11 @@
resolved "https://registry.yarnpkg.com/@types/ungap__global-this/-/ungap__global-this-0.3.1.tgz#18ce9f657da556037a29d50604335614ce703f4c"
integrity sha512-+/DsiV4CxXl6ZWefwHZDXSe1Slitz21tom38qPCaG0DYCS1NnDPIQDTKcmQ/tvK/edJUKkmuIDBJbmKDiB0r/g==
"@types/unist@*":
version "2.0.6"
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d"
integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==
"@types/websocket@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@types/websocket/-/websocket-1.0.4.tgz#1dc497280d8049a5450854dd698ee7e6ea9e60b8"
@ -5768,6 +5780,13 @@ fault@^1.0.0:
dependencies:
format "^0.2.0"
fault@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/fault/-/fault-2.0.1.tgz#d47ca9f37ca26e4bd38374a7c500b5a384755b6c"
integrity sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==
dependencies:
format "^0.2.0"
faye-websocket@^0.11.3:
version "0.11.3"
resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.3.tgz#5c0e9a8968e8912c286639fde977a8b209f2508e"
@ -6397,10 +6416,10 @@ highlight.js@^10.6.0, highlight.js@~10.7.0:
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.2.tgz#89319b861edc66c48854ed1e6da21ea89f847360"
integrity sha512-oFLl873u4usRM9K63j4ME9u3etNF0PLiJhSQ8rdfuL51Wn3zkD6drf9ZW0dOzjnZI22YYG24z30JcmfCZjMgYg==
highlight.js@^11.3.1:
version "11.3.1"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.3.1.tgz#813078ef3aa519c61700f84fe9047231c5dc3291"
integrity sha512-PUhCRnPjLtiLHZAQ5A/Dt5F8cWZeMyj9KRsACsWT+OD6OP0x6dp5OmT5jdx0JgEyPxPZZIPQpRN2TciUT7occw==
highlight.js@^11.3.1, highlight.js@~11.4.0:
version "11.4.0"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.4.0.tgz#34ceadd49e1596ee5aba3d99346cdfd4845ee05a"
integrity sha512-nawlpCBCSASs7EdvZOYOYVkJpGmAOKMYZgZtUqSRqodZE0GRVcFKwo1RcpeOemqh9hyttTdd5wDBwHkuSyUfnA==
hmac-drbg@^1.0.1:
version "1.0.1"
@ -8104,6 +8123,15 @@ lowlight@^1.20.0:
fault "^1.0.0"
highlight.js "~10.7.0"
lowlight@^2.5.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-2.5.0.tgz#723a39fc0d9b911731a395b320519cbb0790ab14"
integrity sha512-OXGUch9JZu4q5r4Ir6QlUp5pBXMxS7NHaclhRiUlxNRcOSK0gtXZcVrsGP4eM7bv0/KDHg/TXQagx/X35EULsA==
dependencies:
"@types/hast" "^2.0.0"
fault "^2.0.0"
highlight.js "~11.4.0"
lru-cache@^4.1.2, lru-cache@^4.1.5:
version "4.1.5"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"