Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-03-14 15:07:59 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-03-14 15:07:59 +0300
commitfd27e4f95b3ea8668e1e67f5a88dfb061909c893 (patch)
tree439bcc5417be41cd2290485292fe512f6cea8110 /app
parent6aa920eeb4ef61ea7e7c77b5e10595507874b927 (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js2
-rw-r--r--app/assets/javascripts/integrations/constants.js4
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue29
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue12
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue15
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue33
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue32
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js39
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js82
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js55
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue16
-rw-r--r--app/controllers/concerns/issuable_actions.rb4
-rw-r--r--app/controllers/groups/application_controller.rb4
-rw-r--r--app/controllers/groups/crm/contacts_controller.rb1
-rw-r--r--app/controllers/groups/crm/organizations_controller.rb1
-rw-r--r--app/controllers/projects/pipelines_controller.rb16
-rw-r--r--app/graphql/mutations/ci/pipeline/retry.rb5
-rw-r--r--app/helpers/groups/crm_settings_helper.rb2
-rw-r--r--app/models/customer_relations/contact.rb18
-rw-r--r--app/models/customer_relations/issue_contact.rb8
-rw-r--r--app/models/customer_relations/organization.rb9
-rw-r--r--app/models/integrations/jira.rb25
-rw-r--r--app/policies/issue_policy.rb2
-rw-r--r--app/serializers/issue_sidebar_basic_entity.rb5
-rw-r--r--app/services/ci/retry_build_service.rb2
-rw-r--r--app/services/ci/retry_pipeline_service.rb17
-rw-r--r--app/services/issues/set_crm_contacts_service.rb2
-rw-r--r--app/views/groups/settings/_permissions.html.haml2
-rw-r--r--app/views/import/shared/_errors.html.haml14
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml2
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] } }