diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-03-14 15:07:59 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-03-14 15:07:59 +0300 |
commit | fd27e4f95b3ea8668e1e67f5a88dfb061909c893 (patch) | |
tree | 439bcc5417be41cd2290485292fe512f6cea8110 /app | |
parent | 6aa920eeb4ef61ea7e7c77b5e10595507874b927 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
32 files changed, 405 insertions, 59 deletions
diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js index 9dc17fcd570..204ac07d401 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -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'); diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js index edc355fdc8d..c5ed5bb08a9 100644 --- a/app/assets/javascripts/integrations/constants.js +++ b/app/assets/javascripts/integrations/constants.js @@ -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', }; diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index 872b8d0b2b7..6e89872ff68 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -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> diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue index 198cabfad81..7cf8e11f162 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue @@ -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"> diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue index bb8d630fb0e..3c06660e7c5 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue @@ -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" /> diff --git a/app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue b/app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue new file mode 100644 index 00000000000..75202209d38 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue @@ -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> diff --git a/app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue b/app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue new file mode 100644 index 00000000000..f36d3b1fbda --- /dev/null +++ b/app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue @@ -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> diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index 6a4d1bb44f2..ac97c9d2743 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -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); } }, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue index 684386883c8..e5a6514624d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -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); diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js new file mode 100644 index 00000000000..cd5cfb6837c --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js @@ -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:'), +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js new file mode 100644 index 00000000000..65d9257903f --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js @@ -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), + }, + }; + }); + }, + }, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js new file mode 100644 index 00000000000..a74ed20362f --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js @@ -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 />'); +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index cd4d9398899..bb25fa15626 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -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" diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 5d4e93363fa..ae90bd59d01 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -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 diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb index f9c875b80b2..bf72ade32d0 100644 --- a/app/controllers/groups/application_controller.rb +++ b/app/controllers/groups/application_controller.rb @@ -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') diff --git a/app/controllers/groups/crm/contacts_controller.rb b/app/controllers/groups/crm/contacts_controller.rb index f00f4d1df25..b59e20d9cea 100644 --- a/app/controllers/groups/crm/contacts_controller.rb +++ b/app/controllers/groups/crm/contacts_controller.rb @@ -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 diff --git a/app/controllers/groups/crm/organizations_controller.rb b/app/controllers/groups/crm/organizations_controller.rb index ab720f490be..f8536b4f538 100644 --- a/app/controllers/groups/crm/organizations_controller.rb +++ b/app/controllers/groups/crm/organizations_controller.rb @@ -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 diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index b6a9f01c9c8..8279bb20769 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -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 diff --git a/app/graphql/mutations/ci/pipeline/retry.rb b/app/graphql/mutations/ci/pipeline/retry.rb index ee93f99703e..895397a96ab 100644 --- a/app/graphql/mutations/ci/pipeline/retry.rb +++ b/app/graphql/mutations/ci/pipeline/retry.rb @@ -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 diff --git a/app/helpers/groups/crm_settings_helper.rb b/app/helpers/groups/crm_settings_helper.rb index ab47ec40b13..d7ca25a9d1b 100644 --- a/app/helpers/groups/crm_settings_helper.rb +++ b/app/helpers/groups/crm_settings_helper.rb @@ -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 diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb index a981351f4a0..4fa2c3fb8cf 100644 --- a/app/models/customer_relations/contact.rb +++ b/app/models/customer_relations/contact.rb @@ -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 diff --git a/app/models/customer_relations/issue_contact.rb b/app/models/customer_relations/issue_contact.rb index 3e9d1e97c8c..dc7a3fd87bc 100644 --- a/app/models/customer_relations/issue_contact.rb +++ b/app/models/customer_relations/issue_contact.rb @@ -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 diff --git a/app/models/customer_relations/organization.rb b/app/models/customer_relations/organization.rb index c206d1e05f5..a23b9d8fe28 100644 --- a/app/models/customer_relations/organization.rb +++ b/app/models/customer_relations/organization.rb @@ -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 diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb index d8883be6820..74ece57000f 100644 --- a/app/models/integrations/jira.rb +++ b/app/models/integrations/jira.rb @@ -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) diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb index c9c13b29643..a667c843bc6 100644 --- a/app/policies/issue_policy.rb +++ b/app/policies/issue_policy.rb @@ -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? } diff --git a/app/serializers/issue_sidebar_basic_entity.rb b/app/serializers/issue_sidebar_basic_entity.rb index 9c6601afd5e..7222b5df425 100644 --- a/app/serializers/issue_sidebar_basic_entity.rb +++ b/app/serializers/issue_sidebar_basic_entity.rb @@ -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') diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index 73c5d0163da..906e5cec4f3 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -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 diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb index 9ad46ca7585..d40643e1513 100644 --- a/app/services/ci/retry_pipeline_service.rb +++ b/app/services/ci/retry_pipeline_service.rb @@ -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 diff --git a/app/services/issues/set_crm_contacts_service.rb b/app/services/issues/set_crm_contacts_service.rb index 2edc944435b..5836097f1fd 100644 --- a/app/services/issues/set_crm_contacts_service.rb +++ b/app/services/issues/set_crm_contacts_service.rb @@ -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 diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index 85dd218942f..dd62c9e118d 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -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, diff --git a/app/views/import/shared/_errors.html.haml b/app/views/import/shared/_errors.html.haml index badd8c1278f..3e8a99c541a 100644 --- a/app/views/import/shared/_errors.html.haml +++ b/app/views/import/shared/_errors.html.haml @@ -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 diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index f966958d2c7..72fa979392e 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -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] } } |