summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-07-15 21:09:26 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-07-15 21:09:26 +0000
commit651917dbac09fc4fe9217c08d68420019dff59fb (patch)
tree6cdda4532d41b7862a33d7f3ab91959c3917d4f0
parentda1962d9ac710f95d350d2645c87f5a663123cf2 (diff)
downloadgitlab-ce-651917dbac09fc4fe9217c08d68420019dff59fb.tar.gz
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--app/assets/javascripts/ci_variable_list/store/mutations.js4
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js9
-rw-r--r--app/assets/javascripts/ide/stores/utils.js7
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_header.vue27
-rw-r--r--app/assets/javascripts/monitoring/monitoring_app.js5
-rw-r--r--app/assets/javascripts/monitoring/pages/dashboard_page.vue11
-rw-r--r--app/assets/javascripts/monitoring/router/constants.js1
-rw-r--r--app/assets/javascripts/monitoring/router/routes.js9
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js4
-rw-r--r--app/assets/javascripts/monitoring/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js4
-rw-r--r--app/assets/javascripts/pages/projects/edit/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js30
-rw-r--r--app/assets/javascripts/pages/projects/issues/service_desk/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue215
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue160
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue169
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/event_hub.js3
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/index.js41
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js27
-rw-r--r--app/assets/javascripts/repository/components/web_ide_link.vue47
-rw-r--r--app/assets/javascripts/repository/index.js21
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js40
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue23
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue19
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js9
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js1
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss4
-rw-r--r--app/assets/stylesheets/pages/service_desk.scss7
-rw-r--r--app/controllers/projects/incident_management/pager_duty_incidents_controller.rb14
-rw-r--r--app/controllers/projects/merge_requests/application_controller.rb9
-rw-r--r--app/controllers/projects/merge_requests/creations_controller.rb4
-rw-r--r--app/finders/ci/pipelines_for_merge_request_finder.rb44
-rw-r--r--app/helpers/issues_helper.rb5
-rw-r--r--app/helpers/projects_helper.rb4
-rw-r--r--app/helpers/tree_helper.rb2
-rw-r--r--app/models/ci/pipeline.rb10
-rw-r--r--app/models/incident_management/project_incident_management_setting.rb2
-rw-r--r--app/models/merge_request.rb6
-rw-r--r--app/services/incident_management/pager_duty/process_webhook_service.rb71
-rw-r--r--app/services/merge_requests/base_service.rb4
-rw-r--r--app/services/merge_requests/create_pipeline_service.rb20
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml2
-rw-r--r--app/views/projects/_service_desk_settings.html.haml19
-rw-r--r--app/views/projects/edit.html.haml2
-rw-r--r--app/views/projects/issues/_alert_moved_from_service_desk.html.haml10
-rw-r--r--app/views/projects/issues/_service_desk_info_content.html.haml39
-rw-r--r--app/views/projects/issues/service_desk.html.haml21
-rw-r--r--app/views/projects/issues/show.html.haml2
-rw-r--r--app/views/projects/sidebar/_issues_service_desk.html.haml3
-rw-r--r--app/views/shared/empty_states/icons/_service_desk_callout.svg1
-rw-r--r--app/views/shared/empty_states/icons/_service_desk_empty_state.svg1
-rw-r--r--app/views/shared/empty_states/icons/_service_desk_setup.svg39
-rw-r--r--app/views/shared/icons/_icon_service_desk.svg1
-rw-r--r--app/views/shared/promotions/_promote_servicedesk.html.haml13
-rw-r--r--app/workers/all_queues.yml752
-rw-r--r--changelogs/unreleased/202159-open-fork.yml6
-rw-r--r--changelogs/unreleased/216022-use-pod-label.yml5
-rw-r--r--changelogs/unreleased/217803-follow-up-from-resolve-distribute-daily-cron-schedules-out-over-th.yml5
-rw-r--r--changelogs/unreleased/36574-custom-renderer-identifiers-instances.yml5
-rw-r--r--changelogs/unreleased/fix-ci-variables-regression.yml5
-rw-r--r--changelogs/unreleased/issue_22856_fe.yml5
-rw-r--r--changelogs/unreleased/rp-use-stable-sort-in-sorter.yml5
-rw-r--r--changelogs/unreleased/short-url-for-custom-metrics-dashboards.yml5
-rw-r--r--config/initializers/1_settings.rb3
-rw-r--r--config/initializers/stackprof.rb1
-rw-r--r--config/prometheus/common_metrics.yml24
-rw-r--r--config/sidekiq_queues.yml2
-rw-r--r--doc/administration/geo/replication/docker_registry.md2
-rw-r--r--doc/administration/object_storage.md2
-rw-r--r--doc/administration/packages/container_registry.md95
-rw-r--r--doc/administration/reference_architectures/2k_users.md2
-rw-r--r--doc/development/secure_coding_guidelines.md75
-rw-r--r--doc/development/telemetry/index.md10
-rw-r--r--doc/development/telemetry/snowplow.md6
-rw-r--r--doc/development/telemetry/usage_ping.md6
-rw-r--r--doc/user/clusters/crossplane.md400
-rw-r--r--doc/user/project/merge_requests/img/merge_when_pipeline_succeeds_only_if_succeeds_msg.pngbin5237 -> 0 bytes
-rw-r--r--doc/user/project/merge_requests/merge_when_pipeline_succeeds.md87
-rw-r--r--lib/api/merge_requests.rb6
-rw-r--r--lib/gitlab/ci/features.rb4
-rw-r--r--lib/gitlab/metrics/dashboard/stages/sorter.rb4
-rw-r--r--lib/gitlab/utils.rb10
-rw-r--r--locale/gitlab.pot33
-rw-r--r--qa/qa/page/project/show.rb2
-rw-r--r--spec/controllers/projects/incident_management/pager_duty_incidents_controller_spec.rb45
-rw-r--r--spec/features/issues/move_spec.rb39
-rw-r--r--spec/features/issues/service_desk_spec.rb163
-rw-r--r--spec/features/merge_request/user_sees_pipelines_spec.rb105
-rw-r--r--spec/features/projects/environments/environment_metrics_spec.rb11
-rw-r--r--spec/features/projects/navbar_spec.rb11
-rw-r--r--spec/features/projects/settings/service_desk_setting_spec.rb33
-rw-r--r--spec/features/promotion_spec.rb53
-rw-r--r--spec/finders/ci/pipelines_for_merge_request_finder_spec.rb90
-rw-r--r--spec/frontend/ci_variable_list/store/mutations_spec.js4
-rw-r--r--spec/frontend/ide/commit_icon_spec.js1
-rw-r--r--spec/frontend/ide/helpers.js1
-rw-r--r--spec/frontend/monitoring/components/dashboard_header_spec.js11
-rw-r--r--spec/frontend/monitoring/components/dashboard_spec.js18
-rw-r--r--spec/frontend/monitoring/fixture_data.js11
-rw-r--r--spec/frontend/monitoring/pages/dashboard_page_spec.js21
-rw-r--r--spec/frontend/monitoring/router_spec.js81
-rw-r--r--spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js121
-rw-r--r--spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js226
-rw-r--r--spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js234
-rw-r--r--spec/frontend/projects/settings_service_desk/services/service_desk_service_spec.js129
-rw-r--r--spec/frontend/repository/components/web_ide_link_spec.js51
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js1
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js55
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js62
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js14
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js29
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js21
-rw-r--r--spec/helpers/issues_helper_spec.rb24
-rw-r--r--spec/helpers/tree_helper_spec.rb54
-rw-r--r--spec/lib/gitlab/utils_spec.rb36
-rw-r--r--spec/requests/api/projects_spec.rb20
-rw-r--r--spec/requests/projects/incident_management/pagerduty_incidents_spec.rb36
-rw-r--r--spec/services/incident_management/pager_duty/process_webhook_service_spec.rb148
-rw-r--r--spec/services/merge_requests/create_pipeline_service_spec.rb53
-rw-r--r--spec/services/merge_requests/create_service_spec.rb7
-rw-r--r--spec/services/merge_requests/refresh_service_spec.rb7
-rw-r--r--spec/support/shared_contexts/navbar_structure_context.rb1
131 files changed, 3796 insertions, 1094 deletions
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index a9899263941..180928936d5 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-fdd1fe70085c1a20b10553680d88a967a4cfbfae
+1f8d26cf17170ece575270e65f298a03cd14d7ae
diff --git a/app/assets/javascripts/ci_variable_list/store/mutations.js b/app/assets/javascripts/ci_variable_list/store/mutations.js
index 8903b742537..961cecee298 100644
--- a/app/assets/javascripts/ci_variable_list/store/mutations.js
+++ b/app/assets/javascripts/ci_variable_list/store/mutations.js
@@ -74,7 +74,7 @@ export default {
variable_type: displayText.variableText,
key: '',
secret_value: '',
- protected: false,
+ protected_variable: false,
masked: false,
environment_scope: displayText.allEnvironmentsText,
};
@@ -103,7 +103,7 @@ export default {
},
[types.SET_VARIABLE_PROTECTED](state) {
- state.variable.protected = true;
+ state.variable.protected_variable = true;
},
[types.UPDATE_VARIABLE_KEY](state, key) {
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index d94adc3760f..ae119c2b1fd 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -1,6 +1,5 @@
export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
export const TOGGLE_LOADING = 'TOGGLE_LOADING';
-export const SET_LAST_COMMIT_DATA = 'SET_LAST_COMMIT_DATA';
export const SET_LAST_COMMIT_MSG = 'SET_LAST_COMMIT_MSG';
export const SET_RESIZING_STATUS = 'SET_RESIZING_STATUS';
export const SET_EMPTY_STATE_SVGS = 'SET_EMPTY_STATE_SVGS';
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
index e827aacac13..c64839e5019 100644
--- a/app/assets/javascripts/ide/stores/mutations.js
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -34,15 +34,6 @@ export default {
panelResizing: resizing,
});
},
- [types.SET_LAST_COMMIT_DATA](state, { entry, lastCommit }) {
- Object.assign(entry.lastCommit, {
- id: lastCommit.commit.id,
- url: lastCommit.commit_path,
- message: lastCommit.commit.message,
- author: lastCommit.commit.author_name,
- updatedAt: lastCommit.commit.authored_date,
- });
- },
[types.SET_LAST_COMMIT_MSG](state, lastCommitMsg) {
Object.assign(state, {
lastCommitMsg,
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
index 1c5fe9fe9a5..f074e6880d0 100644
--- a/app/assets/javascripts/ide/stores/utils.js
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -25,13 +25,6 @@ export const dataStructure = () => ({
changed: false,
staged: false,
lastCommitSha: '',
- lastCommit: {
- id: '',
- url: '',
- message: '',
- updatedAt: '',
- author: '',
- },
rawPath: '',
binary: false,
raw: '',
diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue
index ae8c586ff8c..fe6ca3a2a07 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_header.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue
@@ -127,6 +127,7 @@ export default {
'projectPath',
'canAccessOperationsSettings',
'operationsSettingsPath',
+ 'currentDashboard',
]),
...mapGetters('monitoringDashboard', ['selectedDashboard', 'filteredEnvironments']),
isOutOfTheBoxDashboard() {
@@ -164,11 +165,14 @@ export default {
methods: {
...mapActions('monitoringDashboard', ['filterEnvironments', 'toggleStarredValue']),
selectDashboard(dashboard) {
- const params = {
- dashboard: encodeURIComponent(dashboard.path),
- };
-
- redirectTo(mergeUrlParams(params, window.location.href));
+ // Once the sidebar See metrics link is updated to the new URL,
+ // this sort of hardcoding will not be necessary.
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/229277
+ const baseURL = `${this.projectPath}/-/metrics`;
+ const dashboardPath = encodeURIComponent(
+ dashboard.out_of_the_box_dashboard ? dashboard.path : dashboard.display_name,
+ );
+ redirectTo(`${baseURL}/${dashboardPath}`);
},
debouncedEnvironmentsSearch: debounce(function environmentsSearchOnInput(searchTerm) {
this.filterEnvironments(searchTerm);
@@ -193,6 +197,17 @@ export default {
submitCustomMetricsForm() {
this.$refs.customMetricsForm.submit();
},
+ getEnvironmentPath(environment) {
+ // Once the sidebar See metrics link is updated to the new URL,
+ // this sort of hardcoding will not be necessary.
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/229277
+ const baseURL = `${this.projectPath}/-/metrics`;
+ const dashboardPath = encodeURIComponent(this.currentDashboard || '');
+ // The environment_metrics_spec.rb requires the URL to not have
+ // slashes. Hence, this additional check.
+ const url = dashboardPath ? `${baseURL}/${dashboardPath}` : baseURL;
+ return mergeUrlParams({ environment }, url);
+ },
},
modalIds: {
addMetric: 'addMetric',
@@ -255,7 +270,7 @@ export default {
:key="environment.id"
:active="environment.name === currentEnvironmentName"
active-class="is-active"
- :href="environment.metrics_path"
+ :href="getEnvironmentPath(environment.id)"
>{{ environment.name }}</gl-dropdown-item
>
</div>
diff --git a/app/assets/javascripts/monitoring/monitoring_app.js b/app/assets/javascripts/monitoring/monitoring_app.js
index 6e7df9efbb1..307154c9a84 100644
--- a/app/assets/javascripts/monitoring/monitoring_app.js
+++ b/app/assets/javascripts/monitoring/monitoring_app.js
@@ -1,6 +1,5 @@
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
-import { getParameterValues } from '~/lib/utils/url_utility';
import { createStore } from './stores';
import createRouter from './router';
import { stateAndPropsFromDataset } from './utils';
@@ -11,11 +10,9 @@ export default (props = {}) => {
const el = document.getElementById('prometheus-graphs');
if (el && el.dataset) {
- const [encodedDashboard] = getParameterValues('dashboard');
- const currentDashboard = encodedDashboard ? decodeURIComponent(encodedDashboard) : null;
const { metricsDashboardBasePath, ...dataset } = el.dataset;
- const { initState, dataProps } = stateAndPropsFromDataset({ currentDashboard, ...dataset });
+ const { initState, dataProps } = stateAndPropsFromDataset(dataset);
const store = createStore(initState);
const router = createRouter(metricsDashboardBasePath);
diff --git a/app/assets/javascripts/monitoring/pages/dashboard_page.vue b/app/assets/javascripts/monitoring/pages/dashboard_page.vue
index 519a20d7be3..df0e2d7f8f6 100644
--- a/app/assets/javascripts/monitoring/pages/dashboard_page.vue
+++ b/app/assets/javascripts/monitoring/pages/dashboard_page.vue
@@ -1,4 +1,5 @@
<script>
+import { mapActions } from 'vuex';
import Dashboard from '../components/dashboard.vue';
export default {
@@ -11,6 +12,16 @@ export default {
required: true,
},
},
+ created() {
+ // This is to support the older URL <project>/-/environments/:env_id/metrics?dashboard=:path
+ // and the new format <project>/-/metrics/:dashboardPath
+ const encodedDashboard = this.$route.query.dashboard || this.$route.params.dashboard;
+ const currentDashboard = encodedDashboard ? decodeURIComponent(encodedDashboard) : null;
+ this.setCurrentDashboard({ currentDashboard });
+ },
+ methods: {
+ ...mapActions('monitoringDashboard', ['setCurrentDashboard']),
+ },
};
</script>
<template>
diff --git a/app/assets/javascripts/monitoring/router/constants.js b/app/assets/javascripts/monitoring/router/constants.js
index acfcd03f928..fedfebe33e9 100644
--- a/app/assets/javascripts/monitoring/router/constants.js
+++ b/app/assets/javascripts/monitoring/router/constants.js
@@ -1,3 +1,4 @@
export const BASE_DASHBOARD_PAGE = 'dashboard';
+export const CUSTOM_DASHBOARD_PAGE = 'custom_dashboard';
export default {};
diff --git a/app/assets/javascripts/monitoring/router/routes.js b/app/assets/javascripts/monitoring/router/routes.js
index 1e0cc1715a7..4b82791178a 100644
--- a/app/assets/javascripts/monitoring/router/routes.js
+++ b/app/assets/javascripts/monitoring/router/routes.js
@@ -1,6 +1,6 @@
import DashboardPage from '../pages/dashboard_page.vue';
-import { BASE_DASHBOARD_PAGE } from './constants';
+import { BASE_DASHBOARD_PAGE, CUSTOM_DASHBOARD_PAGE } from './constants';
/**
* Because the cluster health page uses the dashboard
@@ -12,7 +12,12 @@ import { BASE_DASHBOARD_PAGE } from './constants';
export default [
{
name: BASE_DASHBOARD_PAGE,
- path: '*',
+ path: '/',
+ component: DashboardPage,
+ },
+ {
+ name: CUSTOM_DASHBOARD_PAGE,
+ path: '/:dashboard(.*)',
component: DashboardPage,
},
];
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index cac04faae98..3da3aa2cb58 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -97,6 +97,10 @@ export const clearExpandedPanel = ({ commit }) => {
});
};
+export const setCurrentDashboard = ({ commit }, { currentDashboard }) => {
+ commit(types.SET_CURRENT_DASHBOARD, currentDashboard);
+};
+
// All Data
/**
diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js
index e1fa037c5bb..d408628fc4d 100644
--- a/app/assets/javascripts/monitoring/stores/mutation_types.js
+++ b/app/assets/javascripts/monitoring/stores/mutation_types.js
@@ -9,6 +9,8 @@ export const REQUEST_DASHBOARD_STARRING = 'REQUEST_DASHBOARD_STARRING';
export const RECEIVE_DASHBOARD_STARRING_SUCCESS = 'RECEIVE_DASHBOARD_STARRING_SUCCESS';
export const RECEIVE_DASHBOARD_STARRING_FAILURE = 'RECEIVE_DASHBOARD_STARRING_FAILURE';
+export const SET_CURRENT_DASHBOARD = 'SET_CURRENT_DASHBOARD';
+
// Annotations
export const RECEIVE_ANNOTATIONS_SUCCESS = 'RECEIVE_ANNOTATIONS_SUCCESS';
export const RECEIVE_ANNOTATIONS_FAILURE = 'RECEIVE_ANNOTATIONS_FAILURE';
diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js
index 28c6b14a029..744441c8935 100644
--- a/app/assets/javascripts/monitoring/stores/mutations.js
+++ b/app/assets/javascripts/monitoring/stores/mutations.js
@@ -97,6 +97,10 @@ export default {
state.isUpdatingStarredValue = false;
},
+ [types.SET_CURRENT_DASHBOARD](state, currentDashboard) {
+ state.currentDashboard = currentDashboard;
+ },
+
/**
* Deployments and environments
*/
diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js
index 63762e414df..e65c18c07a9 100644
--- a/app/assets/javascripts/pages/projects/edit/index.js
+++ b/app/assets/javascripts/pages/projects/edit/index.js
@@ -8,6 +8,8 @@ import initFilePickers from '~/file_pickers';
import initProjectLoadingSpinner from '../shared/save_project_loader';
import initProjectPermissionsSettings from '../shared/permissions';
import initProjectRemoveModal from '~/projects/project_remove_modal';
+import UserCallout from '~/user_callout';
+import initServiceDesk from '~/projects/settings_service_desk';
document.addEventListener('DOMContentLoaded', () => {
initFilePickers();
@@ -16,6 +18,9 @@ document.addEventListener('DOMContentLoaded', () => {
initProjectRemoveModal();
mountBadgeSettings(PROJECT_BADGE);
+ new UserCallout({ className: 'js-service-desk-callout' }); // eslint-disable-line no-new
+ initServiceDesk();
+
initProjectLoadingSpinner();
initProjectPermissionsSettings();
setupTransferEdit('.js-project-transfer-form', 'select.select2');
diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js b/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js
new file mode 100644
index 00000000000..72003b61c8a
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js
@@ -0,0 +1,30 @@
+/* eslint-disable class-methods-use-this */
+import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
+import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager';
+
+const AUTHOR_PARAM_KEY = 'author_username';
+
+export default class FilteredSearchServiceDesk extends FilteredSearchManager {
+ constructor(supportBotData) {
+ super({
+ page: 'service_desk',
+ filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
+ });
+
+ this.supportBotData = supportBotData;
+ }
+
+ canEdit(tokenName) {
+ return tokenName !== 'author';
+ }
+
+ modifyUrlParams(paramsArray) {
+ const supportBotParamPair = `${AUTHOR_PARAM_KEY}=${this.supportBotData.username}`;
+ const onlyValidParams = paramsArray.filter(param => param.indexOf(AUTHOR_PARAM_KEY) === -1);
+
+ // unshift ensures author param is always first token element
+ onlyValidParams.unshift(supportBotParamPair);
+
+ return onlyValidParams;
+ }
+}
diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/index.js b/app/assets/javascripts/pages/projects/issues/service_desk/index.js
new file mode 100644
index 00000000000..56054f5fc80
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/issues/service_desk/index.js
@@ -0,0 +1,11 @@
+import FilteredSearchServiceDesk from './filtered_search';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const supportBotData = JSON.parse(
+ document.querySelector('.js-service-desk-issues').dataset.supportBot,
+ );
+
+ const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData);
+
+ filteredSearchManager.setup();
+});
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
index 4efabcb7df3..5ef1f959b2c 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
@@ -1,12 +1,19 @@
<script>
-import { GlSprintf, GlLink } from '@gitlab/ui';
+import { GlFormRadio, GlFormRadioGroup, GlLink, GlSprintf } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import { getWeekdayNames } from '~/lib/utils/datetime_utility';
+const KEY_EVERY_DAY = 'everyDay';
+const KEY_EVERY_WEEK = 'everyWeek';
+const KEY_EVERY_MONTH = 'everyMonth';
+const KEY_CUSTOM = 'custom';
+
export default {
components: {
- GlSprintf,
+ GlFormRadio,
+ GlFormRadioGroup,
GlLink,
+ GlSprintf,
},
props: {
initialCronInterval: {
@@ -22,6 +29,7 @@ export default {
randomWeekDayIndex: this.generateRandomWeekDayIndex(),
randomDay: this.generateRandomDay(),
inputNameAttribute: 'schedule[cron]',
+ radioValue: this.initialCronInterval ? KEY_CUSTOM : KEY_EVERY_DAY,
cronInterval: this.initialCronInterval,
cronSyntaxUrl: 'https://en.wikipedia.org/wiki/Cron',
};
@@ -29,14 +37,11 @@ export default {
computed: {
cronIntervalPresets() {
return {
- everyDay: `0 ${this.randomHour} * * *`,
- everyWeek: `0 ${this.randomHour} * * ${this.randomWeekDayIndex}`,
- everyMonth: `0 ${this.randomHour} ${this.randomDay} * *`,
+ [KEY_EVERY_DAY]: `0 ${this.randomHour} * * *`,
+ [KEY_EVERY_WEEK]: `0 ${this.randomHour} * * ${this.randomWeekDayIndex}`,
+ [KEY_EVERY_MONTH]: `0 ${this.randomHour} ${this.randomDay} * *`,
};
},
- intervalIsPreset() {
- return Object.values(this.cronIntervalPresets).includes(this.cronInterval);
- },
formattedTime() {
if (this.randomHour > 12) {
return `${this.randomHour - 12}:00pm`;
@@ -45,24 +50,36 @@ export default {
}
return `${this.randomHour}:00am`;
},
+ radioOptions() {
+ return [
+ {
+ value: KEY_EVERY_DAY,
+ text: sprintf(s__(`Every day (at %{time})`), { time: this.formattedTime }),
+ },
+ {
+ value: KEY_EVERY_WEEK,
+ text: sprintf(s__('Every week (%{weekday} at %{time})'), {
+ weekday: this.weekday,
+ time: this.formattedTime,
+ }),
+ },
+ {
+ value: KEY_EVERY_MONTH,
+ text: sprintf(s__('Every month (Day %{day} at %{time})'), {
+ day: this.randomDay,
+ time: this.formattedTime,
+ }),
+ },
+ {
+ value: KEY_CUSTOM,
+ text: s__('PipelineScheduleIntervalPattern|Custom (%{linkStart}Cron syntax%{linkEnd})'),
+ link: this.cronSyntaxUrl,
+ },
+ ];
+ },
weekday() {
return getWeekdayNames()[this.randomWeekDayIndex];
},
- everyDayText() {
- return sprintf(s__(`Every day (at %{time})`), { time: this.formattedTime });
- },
- everyWeekText() {
- return sprintf(s__('Every week (%{weekday} at %{time})'), {
- weekday: this.weekday,
- time: this.formattedTime,
- });
- },
- everyMonthText() {
- return sprintf(s__('Every month (Day %{day} at %{time})'), {
- day: this.randomDay,
- time: this.formattedTime,
- });
- },
},
watch: {
cronInterval() {
@@ -72,38 +89,18 @@ export default {
gl.pipelineScheduleFieldErrors.updateFormValidityState();
});
},
- },
- // If at the mounting stage the default is still an empty string, we
- // know we are not editing an existing field so we update it so
- // that the default is the first radio option
- mounted() {
- if (this.cronInterval === '') {
- this.cronInterval = this.cronIntervalPresets.everyDay;
- }
+ radioValue: {
+ immediate: true,
+ handler(val) {
+ if (val !== KEY_CUSTOM) {
+ this.cronInterval = this.cronIntervalPresets[val];
+ }
+ },
+ },
},
methods: {
- setCustomInput(e) {
- if (!this.isEditingCustom) {
- this.isEditingCustom = true;
- this.$refs.customInput.click();
- // Because we need to manually trigger the click on the radio btn,
- // it will add a space to update the v-model. If the user is typing
- // and the space is added, it will feel very unituitive so we reset
- // the value to the original
- this.cronInterval = e.target.value;
- }
- if (this.intervalIsPreset) {
- this.isEditingCustom = false;
- }
- },
- toggleCustomInput(shouldEnable) {
- this.isEditingCustom = shouldEnable;
-
- if (shouldEnable) {
- // We need to change the value so other radios don't remain selected
- // because the model (cronInterval) hasn't changed. The server trims it.
- this.cronInterval = `${this.cronInterval} `;
- }
+ onCustomInput() {
+ this.radioValue = KEY_CUSTOM;
},
generateRandomHour() {
return Math.floor(Math.random() * 23);
@@ -119,89 +116,33 @@ export default {
</script>
<template>
- <div class="interval-pattern-form-group">
- <div class="cron-preset-radio-input">
- <input
- id="every-day"
- v-model="cronInterval"
- :name="inputNameAttribute"
- :value="cronIntervalPresets.everyDay"
- class="label-bold"
- type="radio"
- @click="toggleCustomInput(false)"
- />
-
- <label class="label-bold" for="every-day">
- {{ everyDayText }}
- </label>
- </div>
-
- <div class="cron-preset-radio-input">
- <input
- id="every-week"
- v-model="cronInterval"
- :name="inputNameAttribute"
- :value="cronIntervalPresets.everyWeek"
- class="label-bold"
- type="radio"
- @click="toggleCustomInput(false)"
- />
-
- <label class="label-bold" for="every-week">
- {{ everyWeekText }}
- </label>
- </div>
-
- <div class="cron-preset-radio-input">
- <input
- id="every-month"
- v-model="cronInterval"
- :name="inputNameAttribute"
- :value="cronIntervalPresets.everyMonth"
- class="label-bold"
- type="radio"
- @click="toggleCustomInput(false)"
- />
-
- <label class="label-bold" for="every-month">
- {{ everyMonthText }}
- </label>
- </div>
-
- <div class="cron-preset-radio-input">
- <input
- id="custom"
- ref="customInput"
- v-model="cronInterval"
- :name="inputNameAttribute"
- :value="cronInterval"
- class="label-bold"
- type="radio"
- @click="toggleCustomInput(true)"
- />
-
- <label for="custom"> {{ s__('PipelineSheduleIntervalPattern|Custom') }} </label>
-
- <gl-sprintf :message="__('(%{linkStart}Cron syntax%{linkEnd})')">
- <template #link="{content}">
- <gl-link :href="cronSyntaxUrl" target="_blank" class="gl-font-sm">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </div>
-
- <div class="cron-interval-input-wrapper">
- <input
- id="schedule_cron"
- v-model="cronInterval"
- :placeholder="__('Define a custom pattern with cron syntax')"
- :name="inputNameAttribute"
- class="form-control inline cron-interval-input"
- type="text"
- required="true"
- @input="setCustomInput"
- />
- </div>
+ <div>
+ <gl-form-radio-group v-model="radioValue" :name="inputNameAttribute">
+ <gl-form-radio
+ v-for="option in radioOptions"
+ :key="option.value"
+ :value="option.value"
+ :data-testid="option.value"
+ >
+ <gl-sprintf v-if="option.link" :message="option.text">
+ <template #link="{content}">
+ <gl-link :href="option.link" target="_blank" class="gl-font-sm">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ <template v-else>{{ option.text }}</template>
+ </gl-form-radio>
+ </gl-form-radio-group>
+ <input
+ id="schedule_cron"
+ v-model="cronInterval"
+ :placeholder="__('Define a custom pattern with cron syntax')"
+ :name="inputNameAttribute"
+ class="form-control inline cron-interval-input"
+ type="text"
+ required="true"
+ @input="onCustomInput"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
new file mode 100644
index 00000000000..d61569fcd6e
--- /dev/null
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
@@ -0,0 +1,160 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { __ } from '~/locale';
+import ServiceDeskSetting from './service_desk_setting.vue';
+import ServiceDeskService from '../services/service_desk_service';
+import eventHub from '../event_hub';
+
+export default {
+ name: 'ServiceDeskRoot',
+ components: {
+ GlAlert,
+ ServiceDeskSetting,
+ },
+ props: {
+ initialIsEnabled: {
+ type: Boolean,
+ required: true,
+ },
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ initialIncomingEmail: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ selectedTemplate: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ outgoingName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ projectKey: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ templates: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+
+ data() {
+ return {
+ isEnabled: this.initialIsEnabled,
+ incomingEmail: this.initialIncomingEmail,
+ isTemplateSaving: false,
+ isAlertShowing: false,
+ alertVariant: 'danger',
+ alertMessage: '',
+ };
+ },
+
+ created() {
+ eventHub.$on('serviceDeskEnabledCheckboxToggled', this.onEnableToggled);
+ eventHub.$on('serviceDeskTemplateSave', this.onSaveTemplate);
+
+ this.service = new ServiceDeskService(this.endpoint);
+
+ if (this.isEnabled && !this.incomingEmail) {
+ this.fetchIncomingEmail();
+ }
+ },
+
+ beforeDestroy() {
+ eventHub.$off('serviceDeskEnabledCheckboxToggled', this.onEnableToggled);
+ eventHub.$off('serviceDeskTemplateSave', this.onSaveTemplate);
+ },
+
+ methods: {
+ fetchIncomingEmail() {
+ this.service
+ .fetchIncomingEmail()
+ .then(({ data }) => {
+ const email = data.service_desk_address;
+ if (!email) {
+ throw new Error(__("Response didn't include `service_desk_address`"));
+ }
+
+ this.incomingEmail = email;
+ })
+ .catch(() =>
+ this.showAlert(__('An error occurred while fetching the Service Desk address.')),
+ );
+ },
+
+ onEnableToggled(isChecked) {
+ this.isEnabled = isChecked;
+ this.incomingEmail = '';
+
+ this.service
+ .toggleServiceDesk(isChecked)
+ .then(({ data }) => {
+ const email = data.service_desk_address;
+ if (isChecked && !email) {
+ throw new Error(__("Response didn't include `service_desk_address`"));
+ }
+
+ this.incomingEmail = email;
+ })
+ .catch(() => {
+ const message = isChecked
+ ? __('An error occurred while enabling Service Desk.')
+ : __('An error occurred while disabling Service Desk.');
+
+ this.showAlert(message);
+ });
+ },
+
+ onSaveTemplate({ selectedTemplate, outgoingName, projectKey }) {
+ this.isTemplateSaving = true;
+ this.service
+ .updateTemplate({ selectedTemplate, outgoingName, projectKey }, this.isEnabled)
+ .then(() => this.showAlert(__('Template was successfully saved.'), 'success'))
+ .catch(() =>
+ this.showAlert(
+ __('An error occurred while saving the template. Please check if the template exists.'),
+ ),
+ )
+ .finally(() => {
+ this.isTemplateSaving = false;
+ });
+ },
+
+ showAlert(message, variant = 'danger') {
+ this.isAlertShowing = true;
+ this.alertMessage = message;
+ this.alertVariant = variant;
+ },
+
+ onDismiss() {
+ this.isAlertShowing = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-alert v-if="isAlertShowing" class="mb-3" :variant="alertVariant" @dismiss="onDismiss">
+ {{ alertMessage }}
+ </gl-alert>
+ <service-desk-setting
+ :is-enabled="isEnabled"
+ :incoming-email="incomingEmail"
+ :initial-selected-template="selectedTemplate"
+ :initial-outgoing-name="outgoingName"
+ :initial-project-key="projectKey"
+ :templates="templates"
+ :is-template-saving="isTemplateSaving"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
new file mode 100644
index 00000000000..43c20fea43e
--- /dev/null
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
@@ -0,0 +1,169 @@
+<script>
+import { GlDeprecatedButton, GlFormSelect, GlToggle, GlLoadingIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+import tooltip from '~/vue_shared/directives/tooltip';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import eventHub from '../event_hub';
+
+export default {
+ name: 'ServiceDeskSetting',
+ directives: {
+ tooltip,
+ },
+ components: {
+ ClipboardButton,
+ GlDeprecatedButton,
+ GlFormSelect,
+ GlToggle,
+ GlLoadingIcon,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ props: {
+ isEnabled: {
+ type: Boolean,
+ required: true,
+ },
+ incomingEmail: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ initialSelectedTemplate: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ initialOutgoingName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ initialProjectKey: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ templates: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ isTemplateSaving: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ selectedTemplate: this.initialSelectedTemplate,
+ outgoingName: this.initialOutgoingName || __('GitLab Support Bot'),
+ projectKey: this.initialProjectKey,
+ };
+ },
+ computed: {
+ templateOptions() {
+ return [''].concat(this.templates);
+ },
+ hasProjectKeySupport() {
+ return Boolean(this.glFeatures.serviceDeskCustomAddress);
+ },
+ },
+ methods: {
+ onCheckboxToggle(isChecked) {
+ eventHub.$emit('serviceDeskEnabledCheckboxToggled', isChecked);
+ },
+ onSaveTemplate() {
+ eventHub.$emit('serviceDeskTemplateSave', {
+ selectedTemplate: this.selectedTemplate,
+ outgoingName: this.outgoingName,
+ projectKey: this.projectKey,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-toggle
+ id="service-desk-checkbox"
+ :value="isEnabled"
+ class="d-inline-block align-middle mr-1"
+ label="Service desk"
+ label-position="left"
+ @change="onCheckboxToggle"
+ />
+ <label class="align-middle" for="service-desk-checkbox">
+ {{ __('Activate Service Desk') }}
+ </label>
+ <div v-if="isEnabled" class="row mt-3">
+ <div class="col-md-9 mb-0">
+ <strong id="incoming-email-describer" class="d-block mb-1">
+ {{ __('Forward external support email address to') }}
+ </strong>
+ <template v-if="incomingEmail">
+ <div class="input-group">
+ <input
+ ref="service-desk-incoming-email"
+ type="text"
+ class="form-control incoming-email h-auto"
+ :placeholder="__('Incoming email')"
+ :aria-label="__('Incoming email')"
+ aria-describedby="incoming-email-describer"
+ :value="incomingEmail"
+ disabled="true"
+ />
+ <div class="input-group-append">
+ <clipboard-button
+ :title="__('Copy')"
+ :text="incomingEmail"
+ css-class="btn qa-clipboard-button"
+ />
+ </div>
+ </div>
+ </template>
+ <template v-else>
+ <gl-loading-icon :inline="true" />
+ <span class="sr-only">{{ __('Fetching incoming email') }}</span>
+ </template>
+
+ <label for="service-desk-template-select" class="mt-3">
+ {{ __('Template to append to all Service Desk issues') }}
+ </label>
+ <gl-form-select
+ id="service-desk-template-select"
+ v-model="selectedTemplate"
+ :options="templateOptions"
+ />
+ <label for="service-desk-email-from-name" class="mt-3">
+ {{ __('Email display name') }}
+ </label>
+ <input id="service-desk-email-from-name" v-model.trim="outgoingName" class="form-control" />
+ <span class="form-text text-muted">
+ {{ __('Emails sent from Service Desk will have this name') }}
+ </span>
+ <template v-if="hasProjectKeySupport">
+ <label for="service-desk-project-suffix" class="mt-3">
+ {{ __('Project name suffix') }}
+ </label>
+ <input id="service-desk-project-suffix" v-model.trim="projectKey" class="form-control" />
+ <span class="form-text text-muted mb-3">
+ {{
+ __(
+ 'Project name suffix is a user-defined string which will be appended to the project path, and will form the Service Desk email address.',
+ )
+ }}
+ </span>
+ </template>
+ <gl-deprecated-button
+ variant="success"
+ :disabled="isTemplateSaving"
+ @click="onSaveTemplate"
+ >{{ __('Save template') }}</gl-deprecated-button
+ >
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/settings_service_desk/event_hub.js b/app/assets/javascripts/projects/settings_service_desk/event_hub.js
new file mode 100644
index 00000000000..e31806ad199
--- /dev/null
+++ b/app/assets/javascripts/projects/settings_service_desk/event_hub.js
@@ -0,0 +1,3 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js
new file mode 100644
index 00000000000..15c077de72e
--- /dev/null
+++ b/app/assets/javascripts/projects/settings_service_desk/index.js
@@ -0,0 +1,41 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import ServiceDeskRoot from './components/service_desk_root.vue';
+
+export default () => {
+ const serviceDeskRootElement = document.querySelector('.js-service-desk-setting-root');
+ if (serviceDeskRootElement) {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: serviceDeskRootElement,
+ components: {
+ ServiceDeskRoot,
+ },
+ data() {
+ const { dataset } = serviceDeskRootElement;
+ return {
+ initialIsEnabled: parseBoolean(dataset.enabled),
+ endpoint: dataset.endpoint,
+ incomingEmail: dataset.incomingEmail,
+ selectedTemplate: dataset.selectedTemplate,
+ outgoingName: dataset.outgoingName,
+ projectKey: dataset.projectKey,
+ templates: JSON.parse(dataset.templates),
+ };
+ },
+ render(createElement) {
+ return createElement('service-desk-root', {
+ props: {
+ initialIsEnabled: this.initialIsEnabled,
+ endpoint: this.endpoint,
+ initialIncomingEmail: this.incomingEmail,
+ selectedTemplate: this.selectedTemplate,
+ outgoingName: this.outgoingName,
+ projectKey: this.projectKey,
+ templates: this.templates,
+ },
+ });
+ },
+ });
+ }
+};
diff --git a/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js b/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js
new file mode 100644
index 00000000000..d707763c64e
--- /dev/null
+++ b/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js
@@ -0,0 +1,27 @@
+import axios from '~/lib/utils/axios_utils';
+
+class ServiceDeskService {
+ constructor(endpoint) {
+ this.endpoint = endpoint;
+ }
+
+ fetchIncomingEmail() {
+ return axios.get(this.endpoint);
+ }
+
+ toggleServiceDesk(enable) {
+ return axios.put(this.endpoint, { service_desk_enabled: enable });
+ }
+
+ updateTemplate({ selectedTemplate, outgoingName, projectKey = '' }, isEnabled) {
+ const body = {
+ issue_template_key: selectedTemplate,
+ outgoing_name: outgoingName,
+ project_key: projectKey,
+ service_desk_enabled: isEnabled,
+ };
+ return axios.put(this.endpoint, body);
+ }
+}
+
+export default ServiceDeskService;
diff --git a/app/assets/javascripts/repository/components/web_ide_link.vue b/app/assets/javascripts/repository/components/web_ide_link.vue
new file mode 100644
index 00000000000..6549d5a3878
--- /dev/null
+++ b/app/assets/javascripts/repository/components/web_ide_link.vue
@@ -0,0 +1,47 @@
+<script>
+import TreeActionLink from './tree_action_link.vue';
+import { __ } from '~/locale';
+import { webIDEUrl } from '~/lib/utils/url_utility';
+
+export default {
+ components: {
+ TreeActionLink,
+ },
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ refSha: {
+ type: String,
+ required: true,
+ },
+ canPushCode: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ forkPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ showLinkToFork() {
+ return !this.canPushCode && this.forkPath;
+ },
+ text() {
+ return this.showLinkToFork ? __('Edit fork in Web IDE') : __('Web IDE');
+ },
+ path() {
+ const path = this.showLinkToFork ? this.forkPath : this.projectPath;
+ return webIDEUrl(`/${path}/edit/${this.refSha}/-/${this.$route.params.path || ''}`);
+ },
+ },
+};
+</script>
+
+<template>
+ <tree-action-link :path="path" :text="text" data-qa-selector="web_ide_button" />
+</template>
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 01db4e363ba..4f80ab4ff5d 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -4,18 +4,26 @@ import App from './components/app.vue';
import Breadcrumbs from './components/breadcrumbs.vue';
import LastCommit from './components/last_commit.vue';
import TreeActionLink from './components/tree_action_link.vue';
+import WebIdeLink from './components/web_ide_link.vue';
import DirectoryDownloadLinks from './components/directory_download_links.vue';
import apolloProvider from './graphql';
import { setTitle } from './utils/title';
import { updateFormAction } from './utils/dom';
import { parseBoolean } from '../lib/utils/common_utils';
-import { webIDEUrl } from '../lib/utils/url_utility';
import { __ } from '../locale';
export default function setupVueRepositoryList() {
const el = document.getElementById('js-tree-list');
const { dataset } = el;
- const { projectPath, projectShortPath, ref, escapedRef, fullName } = dataset;
+ const {
+ canPushCode,
+ projectPath,
+ projectShortPath,
+ forkPath,
+ ref,
+ escapedRef,
+ fullName,
+ } = dataset;
const router = createRouter(projectPath, escapedRef);
apolloProvider.clients.defaultClient.cache.writeData({
@@ -117,11 +125,12 @@ export default function setupVueRepositoryList() {
el: webIdeLinkEl,
router,
render(h) {
- return h(TreeActionLink, {
+ return h(WebIdeLink, {
props: {
- path: webIDEUrl(`/${projectPath}/edit/${ref}/-/${this.$route.params.path || ''}`),
- text: __('Web IDE'),
- cssClass: 'qa-web-ide-button',
+ projectPath,
+ refSha: ref,
+ forkPath,
+ canPushCode: parseBoolean(canPushCode),
},
});
},
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js
index 34cb74efabe..70d29b5b3df 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js
@@ -1,6 +1,7 @@
import renderBlockHtml from './renderers/render_html_block';
import renderKramdownList from './renderers/render_kramdown_list';
import renderKramdownText from './renderers/render_kramdown_text';
+import renderIdentifierInstanceText from './renderers/render_identifier_instance_text';
import renderIdentifierParagraph from './renderers/render_identifier_paragraph';
import renderEmbeddedRubyText from './renderers/render_embedded_ruby_text';
import renderFontAwesomeHtmlInline from './renderers/render_font_awesome_html_inline';
@@ -9,7 +10,7 @@ const htmlInlineRenderers = [renderFontAwesomeHtmlInline];
const htmlBlockRenderers = [renderBlockHtml];
const listRenderers = [renderKramdownList];
const paragraphRenderers = [renderIdentifierParagraph];
-const textRenderers = [renderKramdownText, renderEmbeddedRubyText];
+const textRenderers = [renderKramdownText, renderEmbeddedRubyText, renderIdentifierInstanceText];
const executeRenderer = (renderers, node, context) => {
const availableRenderer = renderers.find(renderer => renderer.canRender(node, context));
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js
index 6937d2acb47..5b89390932c 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js
@@ -32,6 +32,8 @@ export const buildUneditableCloseTokens = (token, tagType = TAG_TYPES.block) =>
// Complete helpers (open plus close)
+export const buildTextToken = content => buildToken('text', null, { content });
+
export const buildUneditableTokens = token => {
return [...buildUneditableOpenTokens(token), buildUneditableCloseToken()];
};
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js
new file mode 100644
index 00000000000..a9c3dfcd728
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js
@@ -0,0 +1,40 @@
+import { buildTextToken, buildUneditableInlineTokens } from './build_uneditable_token';
+
+/*
+Use case examples:
+- Majority: two bracket pairs, back-to-back, each with content (including spaces)
+ - `[environment terraform plans][terraform]`
+ - `[an issue labelled `~"master:broken"`][broken-master-issues]`
+- Minority: two bracket pairs the latter being empty or only one pair with content (including spaces)
+ - `[this link][]`
+ - `[this link]`
+
+Regexp notes:
+ - `(?:\[.+?\]){1}`: Always one bracket pair with content (including spaces)
+ - `(?:\[\]|\[.+?\])?`: Optional second pair that may or may not contain content (including spaces)
+ - `(?!:)`: Never followed by a `:` which is reserved for identifier definition syntax (`[identifier]: /the-link`)
+ - Each of the three parts is non-captured, but the match as a whole is captured
+*/
+const identifierInstanceRegex = /((?:\[.+?\]){1}(?:\[\]|\[.+?\])?(?!:))/g;
+
+const isIdentifierInstance = literal => {
+ // Reset lastIndex as global flag in regexp are stateful (https://stackoverflow.com/a/11477448)
+ identifierInstanceRegex.lastIndex = 0;
+ return identifierInstanceRegex.test(literal);
+};
+
+const canRender = ({ literal }) => isIdentifierInstance(literal);
+
+const tokenize = text => {
+ const matches = text.split(identifierInstanceRegex);
+ const tokens = matches.map(match => {
+ const token = buildTextToken(match);
+ return isIdentifierInstance(match) ? buildUneditableInlineTokens(token) : token;
+ });
+
+ return tokens.flat();
+};
+
+const render = (_, { origin }) => tokenize(origin().content);
+
+export default { canRender, render };
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js
index e94e7d46f85..746e38e98e8 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js
@@ -1,6 +1,7 @@
export const DropdownVariant = {
Sidebar: 'sidebar',
Standalone: 'standalone',
+ Embedded: 'embedded',
};
export const LIST_BUFFER_SIZE = 5;
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
index f45c14f8344..cf77aa37d14 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
@@ -8,12 +8,16 @@ export default {
GlIcon,
},
computed: {
- ...mapGetters(['dropdownButtonText', 'isDropdownVariantStandalone']),
+ ...mapGetters([
+ 'dropdownButtonText',
+ 'isDropdownVariantStandalone',
+ 'isDropdownVariantEmbedded',
+ ]),
},
methods: {
...mapActions(['toggleDropdownContents']),
handleButtonClick(e) {
- if (this.isDropdownVariantStandalone) {
+ if (this.isDropdownVariantStandalone || this.isDropdownVariantEmbedded) {
this.toggleDropdownContents();
e.stopPropagation();
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
index ba8d8391952..94671f8a109 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
@@ -88,12 +88,16 @@ export default {
@click.prevent="handleColorClick(color)"
/>
</div>
- <div class="color-input-container d-flex">
+ <div class="color-input-container gl-display-flex">
<span
class="dropdown-label-color-preview position-relative position-relative d-inline-block"
:style="{ backgroundColor: selectedColor }"
></span>
- <gl-form-input v-model.trim="selectedColor" :placeholder="__('Use custom color #FF0000')" />
+ <gl-form-input
+ v-model.trim="selectedColor"
+ class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
+ :placeholder="__('Use custom color #FF0000')"
+ />
</div>
</div>
<div class="dropdown-actions clearfix pt-2 px-2">
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
index af16088b6b9..ef506d00d9a 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
@@ -36,7 +36,7 @@ export default {
'footerCreateLabelTitle',
'footerManageLabelTitle',
]),
- ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar']),
+ ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar', 'isDropdownVariantEmbedded']),
visibleLabels() {
if (this.searchKey) {
return this.labels.filter(label =>
@@ -126,16 +126,19 @@ export default {
<div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown">
<gl-loading-icon
v-if="labelsFetchInProgress"
- class="labels-fetch-loading position-absolute d-flex align-items-center w-100 h-100"
+ class="labels-fetch-loading position-absolute gl-display-flex gl-align-items-center w-100 h-100"
size="md"
/>
- <div v-if="isDropdownVariantSidebar" class="dropdown-title d-flex align-items-center pt-0 pb-2">
+ <div
+ v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
+ class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
+ >
<span class="flex-grow-1">{{ labelsListTitle }}</span>
<gl-button
:aria-label="__('Close')"
variant="link"
size="small"
- class="dropdown-header-button p-0"
+ class="dropdown-header-button gl-p-0!"
icon="close"
@click="toggleDropdownContents"
/>
@@ -165,17 +168,21 @@ export default {
</li>
</smart-virtual-list>
</div>
- <div v-if="isDropdownVariantSidebar" class="dropdown-footer">
+ <div v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" class="dropdown-footer">
<ul class="list-unstyled">
<li v-if="allowLabelCreate">
<gl-link
- class="d-flex w-100 flex-row text-break-word label-item"
+ class="gl-display-flex w-100 flex-row text-break-word label-item"
@click="toggleDropdownContentsCreateView"
- >{{ footerCreateLabelTitle }}</gl-link
>
+ {{ footerCreateLabelTitle }}
+ </gl-link>
</li>
<li>
- <gl-link :href="labelsManagePath" class="d-flex flex-row text-break-word label-item">
+ <gl-link
+ :href="labelsManagePath"
+ class="gl-display-flex flex-row text-break-word label-item"
+ >
{{ footerManageLabelTitle }}
</gl-link>
</li>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
index f38b66fdfdf..258a87e62b9 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
@@ -74,6 +74,11 @@ export default {
required: false,
default: '',
},
+ dropdownButtonText: {
+ type: String,
+ required: false,
+ default: __('Label'),
+ },
labelsListTitle: {
type: String,
required: false,
@@ -97,7 +102,11 @@ export default {
},
computed: {
...mapState(['showDropdownButton', 'showDropdownContents']),
- ...mapGetters(['isDropdownVariantSidebar', 'isDropdownVariantStandalone']),
+ ...mapGetters([
+ 'isDropdownVariantSidebar',
+ 'isDropdownVariantStandalone',
+ 'isDropdownVariantEmbedded',
+ ]),
dropdownButtonVisible() {
return this.isDropdownVariantSidebar ? this.showDropdownButton : true;
},
@@ -116,6 +125,7 @@ export default {
allowLabelCreate: this.allowLabelCreate,
allowMultiselect: this.allowMultiselect,
allowScopedLabels: this.allowScopedLabels,
+ dropdownButtonText: this.dropdownButtonText,
selectedLabels: this.selectedLabels,
labelsFetchPath: this.labelsFetchPath,
labelsManagePath: this.labelsManagePath,
@@ -200,7 +210,10 @@ export default {
<template>
<div
class="labels-select-wrapper position-relative"
- :class="{ 'is-standalone': isDropdownVariantStandalone }"
+ :class="{
+ 'is-standalone': isDropdownVariantStandalone,
+ 'is-embedded': isDropdownVariantEmbedded,
+ }"
>
<template v-if="isDropdownVariantSidebar">
<dropdown-value-collapsed
@@ -221,7 +234,7 @@ export default {
ref="dropdownContents"
/>
</template>
- <template v-if="isDropdownVariantStandalone">
+ <template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded">
<dropdown-button v-show="dropdownButtonVisible" />
<dropdown-contents
v-if="dropdownButtonVisible && showDropdownContents"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
index c39222959a9..e035a866048 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
@@ -13,7 +13,7 @@ export const dropdownButtonText = (state, getters) => {
: state.selectedLabels;
if (!selectedLabels.length) {
- return __('Label');
+ return state.dropdownButtonText || __('Label');
} else if (selectedLabels.length > 1) {
return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), {
firstLabelName: selectedLabels[0].title,
@@ -44,5 +44,12 @@ export const isDropdownVariantSidebar = state => state.variant === DropdownVaria
*/
export const isDropdownVariantStandalone = state => state.variant === DropdownVariant.Standalone;
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `embedded`
+ * @param {object} state
+ */
+export const isDropdownVariantEmbedded = state => state.variant === DropdownVariant.Embedded;
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
index 6a6c0b4c0ee..3f3358d4805 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
@@ -6,6 +6,7 @@ export default () => ({
labelsCreateTitle: '',
footerCreateLabelTitle: '',
footerManageLabelTitle: '',
+ dropdownButtonText: '',
// Paths
namespace: '',
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 9da0b0da598..32c276ea6d2 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -1089,6 +1089,10 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
.dropdown-label-color-preview {
border: 1px solid $gray-100;
border-right: 0;
+
+ &[style] {
+ border-color: transparent;
+ }
}
}
}
diff --git a/app/assets/stylesheets/pages/service_desk.scss b/app/assets/stylesheets/pages/service_desk.scss
new file mode 100644
index 00000000000..34ab5eb1b74
--- /dev/null
+++ b/app/assets/stylesheets/pages/service_desk.scss
@@ -0,0 +1,7 @@
+.service-desk-issues {
+ .non-empty-state {
+ text-align: left;
+ padding-bottom: $gl-padding-top;
+ border-bottom: 1px solid $border-color;
+ }
+}
diff --git a/app/controllers/projects/incident_management/pager_duty_incidents_controller.rb b/app/controllers/projects/incident_management/pager_duty_incidents_controller.rb
index 45c288243d7..dac1640dd08 100644
--- a/app/controllers/projects/incident_management/pager_duty_incidents_controller.rb
+++ b/app/controllers/projects/incident_management/pager_duty_incidents_controller.rb
@@ -11,11 +11,7 @@ module Projects
prepend_before_action :project_without_auth
def create
- result = ServiceResponse.success(http_status: :accepted)
-
- unless Feature.enabled?(:pagerduty_webhook, @project)
- result = ServiceResponse.error(message: 'Unauthorized', http_status: :unauthorized)
- end
+ result = webhook_processor.execute(params[:token])
head result.http_status
end
@@ -26,6 +22,14 @@ module Projects
@project ||= Project
.find_by_full_path("#{params[:namespace_id]}/#{params[:project_id]}")
end
+
+ def webhook_processor
+ ::IncidentManagement::PagerDuty::ProcessWebhookService.new(project, nil, payload)
+ end
+
+ def payload
+ @payload ||= params.permit![:pager_duty_incident].to_h
+ end
end
end
end
diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb
index b7e99cb7ed0..0bb4e0fb5ee 100644
--- a/app/controllers/projects/merge_requests/application_controller.rb
+++ b/app/controllers/projects/merge_requests/application_controller.rb
@@ -48,12 +48,9 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
end
def set_pipeline_variables
- @pipelines =
- if can?(current_user, :read_pipeline, @merge_request.source_project)
- @merge_request.all_pipelines
- else
- Ci::Pipeline.none
- end
+ @pipelines = Ci::PipelinesForMergeRequestFinder
+ .new(@merge_request, current_user)
+ .execute
end
def close_merge_request_if_no_source_project
diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb
index 28aa1b300aa..3e077c1af37 100644
--- a/app/controllers/projects/merge_requests/creations_controller.rb
+++ b/app/controllers/projects/merge_requests/creations_controller.rb
@@ -32,13 +32,13 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
end
def pipelines
- @pipelines = @merge_request.all_pipelines
+ @pipelines = Ci::PipelinesForMergeRequestFinder.new(@merge_request, current_user).execute
Gitlab::PollingInterval.set_header(response, interval: 10_000)
render json: {
pipelines: PipelineSerializer
- .new(project: @project, current_user: @current_user)
+ .new(project: @project, current_user: current_user)
.represent(@pipelines)
}
end
diff --git a/app/finders/ci/pipelines_for_merge_request_finder.rb b/app/finders/ci/pipelines_for_merge_request_finder.rb
index c01a68d6749..93d139652b9 100644
--- a/app/finders/ci/pipelines_for_merge_request_finder.rb
+++ b/app/finders/ci/pipelines_for_merge_request_finder.rb
@@ -7,14 +7,29 @@ module Ci
EVENT = 'merge_request_event'
- def initialize(merge_request)
+ def initialize(merge_request, current_user)
@merge_request = merge_request
+ @current_user = current_user
end
- attr_reader :merge_request
+ attr_reader :merge_request, :current_user
- delegate :commit_shas, :source_project, :source_branch, to: :merge_request
+ delegate :commit_shas, :target_project, :source_project, :source_branch, to: :merge_request
+ # Fetch all pipelines that the user can read.
+ def execute
+ if can_read_pipeline_in_target_project? && can_read_pipeline_in_source_project?
+ all
+ elsif can_read_pipeline_in_source_project?
+ all.for_project(merge_request.source_project)
+ elsif can_read_pipeline_in_target_project?
+ all.for_project(merge_request.target_project)
+ else
+ Ci::Pipeline.none
+ end
+ end
+
+ # Fetch all pipelines without permission check.
def all
strong_memoize(:all_pipelines) do
next Ci::Pipeline.none unless source_project
@@ -35,13 +50,13 @@ module Ci
def pipelines_using_cte
cte = Gitlab::SQL::CTE.new(:shas, merge_request.all_commits.select(:sha))
- source_pipelines_join = cte.table[:sha].eq(Ci::Pipeline.arel_table[:source_sha])
- source_pipelines = filter_by(triggered_by_merge_request, cte, source_pipelines_join)
- detached_pipelines = filter_by_sha(triggered_by_merge_request, cte)
+ source_sha_join = cte.table[:sha].eq(Ci::Pipeline.arel_table[:source_sha])
+ merged_result_pipelines = filter_by(triggered_by_merge_request, cte, source_sha_join)
+ detached_merge_request_pipelines = filter_by_sha(triggered_by_merge_request, cte)
pipelines_for_branch = filter_by_sha(triggered_for_branch, cte)
Ci::Pipeline.with(cte.to_arel) # rubocop: disable CodeReuse/ActiveRecord
- .from_union([source_pipelines, detached_pipelines, pipelines_for_branch])
+ .from_union([merged_result_pipelines, detached_merge_request_pipelines, pipelines_for_branch])
end
def filter_by_sha(pipelines, cte)
@@ -65,8 +80,7 @@ module Ci
# NOTE: this method returns only parent merge request pipelines.
# Child merge request pipelines have a different source.
def triggered_by_merge_request
- source_project.ci_pipelines
- .where(source: :merge_request_event, merge_request: merge_request) # rubocop: disable CodeReuse/ActiveRecord
+ Ci::Pipeline.triggered_by_merge_request(merge_request)
end
def triggered_for_branch
@@ -86,5 +100,17 @@ module Ci
pipelines.order(Arel.sql(query)) # rubocop: disable CodeReuse/ActiveRecord
end
+
+ def can_read_pipeline_in_target_project?
+ strong_memoize(:can_read_pipeline_in_target_project) do
+ Ability.allowed?(current_user, :read_pipeline, target_project)
+ end
+ end
+
+ def can_read_pipeline_in_source_project?
+ strong_memoize(:can_read_pipeline_in_source_project) do
+ Ability.allowed?(current_user, :read_pipeline, source_project)
+ end
+ end
end
end
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 495706f621a..61fe075303c 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -132,7 +132,10 @@ module IssuesHelper
end
def show_moved_service_desk_issue_warning?(issue)
- false
+ return false unless issue.moved_from
+ return false unless issue.from_service_desk?
+
+ issue.moved_from.project.service_desk_enabled? && !issue.project.service_desk_enabled?
end
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index ed4285eb0fd..840e3ef9daa 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -750,10 +750,6 @@ module ProjectsHelper
::Feature.enabled?(:resource_access_token, project)
end
-
- def render_service_desk_menu?
- false
- end
end
ProjectsHelper.prepend_if_ee('EE::ProjectsHelper')
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index 4dc00581703..90a5b6da4c7 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -191,8 +191,10 @@ module TreeHelper
def vue_file_list_data(project, ref)
{
+ can_push_code: current_user&.can?(:push_code, project) && "true",
project_path: project.full_path,
project_short_path: project.path,
+ fork_path: current_user&.fork_of(project)&.full_path,
ref: ref,
escaped_ref: ActionDispatch::Journey::Router::Utils.escape_path(ref),
full_name: project.name_with_namespace
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 7c4170698ef..fc144ac5b86 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -269,6 +269,7 @@ module Ci
scope :for_ref, -> (ref) { where(ref: ref) }
scope :for_id, -> (id) { where(id: id) }
scope :for_iid, -> (iid) { where(iid: iid) }
+ scope :for_project, -> (project) { where(project: project) }
scope :created_after, -> (time) { where('ci_pipelines.created_at > ?', time) }
scope :created_before_id, -> (id) { where('ci_pipelines.id < ?', id) }
scope :before_pipeline, -> (pipeline) { created_before_id(pipeline.id).outside_pipeline_family(pipeline) }
@@ -289,6 +290,15 @@ module Ci
)
end
+ # Returns the pipelines that associated with the given merge request.
+ # In general, please use `Ci::PipelinesForMergeRequestFinder` instead,
+ # for checking permission of the actor.
+ scope :triggered_by_merge_request, -> (merge_request) do
+ ci_sources.where(source: :merge_request_event,
+ merge_request: merge_request,
+ project: [merge_request.source_project, merge_request.target_project])
+ end
+
# Returns the pipelines in descending order (= newest first), optionally
# limited to a number of references.
#
diff --git a/app/models/incident_management/project_incident_management_setting.rb b/app/models/incident_management/project_incident_management_setting.rb
index 61ca5183719..c79acdb685f 100644
--- a/app/models/incident_management/project_incident_management_setting.rb
+++ b/app/models/incident_management/project_incident_management_setting.rb
@@ -12,7 +12,7 @@ module IncidentManagement
attr_encrypted :pagerduty_token,
mode: :per_attribute_iv,
- key: Settings.attr_encrypted_db_key_base_truncated,
+ key: ::Settings.attr_encrypted_db_key_base_truncated,
algorithm: 'aes-256-gcm',
encode: false, # No need to encode for binary column https://github.com/attr-encrypted/attr_encrypted#the-encode-encode_iv-encode_salt-and-default_encoding-options
encode_iv: false
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 3a6b9cd557a..b7885771781 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -1028,6 +1028,10 @@ class MergeRequest < ApplicationRecord
target_project != source_project
end
+ def for_same_project?
+ target_project == source_project
+ end
+
# If the merge request closes any issues, save this information in the
# `MergeRequestsClosingIssues` model. This is a performance optimization.
# Calculating this information for a number of merge requests requires
@@ -1293,7 +1297,7 @@ class MergeRequest < ApplicationRecord
def all_pipelines
strong_memoize(:all_pipelines) do
- Ci::PipelinesForMergeRequestFinder.new(self).all
+ Ci::PipelinesForMergeRequestFinder.new(self, nil).all
end
end
diff --git a/app/services/incident_management/pager_duty/process_webhook_service.rb b/app/services/incident_management/pager_duty/process_webhook_service.rb
new file mode 100644
index 00000000000..5dd3186694a
--- /dev/null
+++ b/app/services/incident_management/pager_duty/process_webhook_service.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+module IncidentManagement
+ module PagerDuty
+ class ProcessWebhookService < BaseService
+ include Gitlab::Utils::StrongMemoize
+ include IncidentManagement::Settings
+
+ # https://developer.pagerduty.com/docs/webhooks/webhook-behavior/#size-limit
+ PAGER_DUTY_PAYLOAD_SIZE_LIMIT = 55.kilobytes
+
+ # https://developer.pagerduty.com/docs/webhooks/v2-overview/#webhook-types
+ PAGER_DUTY_PROCESSABLE_EVENT_TYPES = %w(incident.trigger).freeze
+
+ def execute(token)
+ return forbidden unless webhook_setting_active?
+ return unauthorized unless valid_token?(token)
+ return bad_request unless valid_payload_size?
+
+ process_incidents
+
+ accepted
+ end
+
+ private
+
+ def process_incidents
+ pager_duty_processable_events.each do |event|
+ ::IncidentManagement::PagerDuty::ProcessIncidentWorker.perform_async(project.id, event['incident'])
+ end
+ end
+
+ def pager_duty_processable_events
+ strong_memoize(:pager_duty_processable_events) do
+ ::PagerDuty::WebhookPayloadParser
+ .call(params.to_h)
+ .filter { |msg| msg['event'].in?(PAGER_DUTY_PROCESSABLE_EVENT_TYPES) }
+ end
+ end
+
+ def webhook_setting_active?
+ Feature.enabled?(:pagerduty_webhook, project) &&
+ incident_management_setting.pagerduty_active?
+ end
+
+ def valid_token?(token)
+ token && incident_management_setting.pagerduty_token == token
+ end
+
+ def valid_payload_size?
+ Gitlab::Utils::DeepSize.new(params, max_size: PAGER_DUTY_PAYLOAD_SIZE_LIMIT).valid?
+ end
+
+ def accepted
+ ServiceResponse.success(http_status: :accepted)
+ end
+
+ def forbidden
+ ServiceResponse.error(message: 'Forbidden', http_status: :forbidden)
+ end
+
+ def unauthorized
+ ServiceResponse.error(message: 'Unauthorized', http_status: :unauthorized)
+ end
+
+ def bad_request
+ ServiceResponse.error(message: 'Bad Request', http_status: :bad_request)
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index 3940e3b7f1a..7e301f311e9 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -102,10 +102,6 @@ module MergeRequests
MergeRequests::CreatePipelineService.new(project, user).execute(merge_request)
end
- def can_use_merge_request_ref?(merge_request)
- !merge_request.for_fork?
- end
-
def abort_auto_merge(merge_request, reason)
AutoMergeService.new(project, current_user).abort(merge_request, reason)
end
diff --git a/app/services/merge_requests/create_pipeline_service.rb b/app/services/merge_requests/create_pipeline_service.rb
index f802aa44487..f9352f10fea 100644
--- a/app/services/merge_requests/create_pipeline_service.rb
+++ b/app/services/merge_requests/create_pipeline_service.rb
@@ -9,7 +9,7 @@ module MergeRequests
end
def create_detached_merge_request_pipeline(merge_request)
- Ci::CreatePipelineService.new(merge_request.source_project,
+ Ci::CreatePipelineService.new(pipeline_project(merge_request),
current_user,
ref: pipeline_ref_for_detached_merge_request_pipeline(merge_request))
.execute(:merge_request_event, merge_request: merge_request)
@@ -31,13 +31,29 @@ module MergeRequests
private
+ def pipeline_project(merge_request)
+ if can_create_pipeline_in_target_project?(merge_request)
+ merge_request.target_project
+ else
+ merge_request.source_project
+ end
+ end
+
def pipeline_ref_for_detached_merge_request_pipeline(merge_request)
- if can_use_merge_request_ref?(merge_request)
+ if can_create_pipeline_in_target_project?(merge_request)
merge_request.ref_path
else
merge_request.source_branch
end
end
+
+ def can_create_pipeline_in_target_project?(merge_request)
+ if Gitlab::Ci::Features.allow_to_create_merge_request_pipelines_in_target_project?(merge_request.target_project)
+ can?(current_user, :create_pipeline, merge_request.target_project)
+ else
+ merge_request.for_same_project?
+ end
+ end
end
end
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 44d71cd6e65..30bda218b17 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -114,7 +114,7 @@
%span
= _('Labels')
- = render_if_exists 'projects/sidebar/issues_service_desk'
+ = render 'projects/sidebar/issues_service_desk'
= nav_link(controller: :milestones) do
= link_to project_milestones_path(@project), title: _('Milestones'), class: 'qa-milestones-link' do
diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml
new file mode 100644
index 00000000000..e6842bbb939
--- /dev/null
+++ b/app/views/projects/_service_desk_settings.html.haml
@@ -0,0 +1,19 @@
+- expanded = expanded_by_default?
+%section.settings.js-service-desk-setting-wrapper.no-animate#js-service-desk{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Service Desk')
+ %button.btn.js-settings-toggle
+ = expanded ? _('Collapse') : _('Expand')
+ - link_start = "<a href='#{help_page_path('user/project/service_desk')}' target='_blank' rel='noopener noreferrer'>".html_safe
+ %p= _('Enable/disable your service desk. %{link_start}Learn more about service desk%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+ .settings-content
+ - if ::Gitlab::ServiceDesk.supported?
+ .js-service-desk-setting-root{ data: { endpoint: project_service_desk_path(@project),
+ enabled: "#{@project.service_desk_enabled}",
+ incoming_email: (@project.service_desk_address if @project.service_desk_enabled),
+ selected_template: "#{@project.service_desk_setting&.issue_template_key}",
+ outgoing_name: "#{@project.service_desk_setting&.outgoing_name}",
+ project_key: "#{@project.service_desk_setting&.project_key}",
+ templates: issuable_templates_names(Issue.new) } }
+ - elsif show_callout?('promote_service_desk_dismissed')
+ = render 'shared/promotions/promote_servicedesk'
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 3c6fb5b19a4..e63b615115a 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -56,7 +56,7 @@
= render_if_exists 'projects/settings/default_issue_template'
-= render_if_exists 'projects/service_desk_settings'
+= render 'projects/service_desk_settings'
%section.qa-advanced-settings.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded) }
.settings-header
diff --git a/app/views/projects/issues/_alert_moved_from_service_desk.html.haml b/app/views/projects/issues/_alert_moved_from_service_desk.html.haml
new file mode 100644
index 00000000000..a6f969f8b10
--- /dev/null
+++ b/app/views/projects/issues/_alert_moved_from_service_desk.html.haml
@@ -0,0 +1,10 @@
+- return unless show_moved_service_desk_issue_warning?(issue)
+- service_desk_link_url = help_page_path('user/project/service_desk')
+- service_desk_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: service_desk_link_url }
+
+.hide.gl-alert.gl-alert-warning.js-alert-moved-from-service-desk-warning.gl-mt-5{ role: 'alert' }
+ = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
+ = sprite_icon('close', size: 16, css_class: 'gl-icon')
+ .gl-alert-body.gl-mr-3
+ = s_('This project does not have %{service_desk_link_start}Service Desk%{service_desk_link_end} enabled, so the user who created the issue will no longer receive email notifications about new activity.').html_safe % { service_desk_link_start: service_desk_link_start, service_desk_link_end: '</a>'.html_safe }
diff --git a/app/views/projects/issues/_service_desk_info_content.html.haml b/app/views/projects/issues/_service_desk_info_content.html.haml
new file mode 100644
index 00000000000..ddd8e545043
--- /dev/null
+++ b/app/views/projects/issues/_service_desk_info_content.html.haml
@@ -0,0 +1,39 @@
+- is_empty_state = @issues.blank?
+- service_desk_enabled = @project.service_desk_enabled?
+
+- callout_selector = is_empty_state ? 'empty-state' : 'non-empty-state media'
+- svg_path = !is_empty_state ? 'shared/empty_states/icons/service_desk_callout.svg' : 'shared/empty_states/icons/service_desk_empty_state.svg'
+- can_edit_project_settings = can?(current_user, :admin_project, @project)
+- title_text = _("Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab")
+
+- if Gitlab::ServiceDesk.supported?
+ %div{ class: "#{callout_selector}" }
+ .svg-content
+ = render svg_path
+
+ %div{ class: is_empty_state ? "text-content" : "prepend-top-10 gl-ml-3" }
+ - if is_empty_state
+ %h4= title_text
+ - else
+ %h5= title_text
+
+ - if can_edit_project_settings && service_desk_enabled
+ %p
+ = _("Have your users email")
+ %code= @project.service_desk_address
+
+ %span= _("Those emails automatically become issues (with the comments becoming the email conversation) listed here.")
+ = link_to _('Read more'), help_page_path('user/project/service_desk')
+
+ - if can_edit_project_settings && !service_desk_enabled
+ %div{ class: is_empty_state ? "text-center" : "prepend-top-10" }
+ = link_to _("Turn on Service Desk"), edit_project_path(@project), class: 'btn btn-success'
+- else
+ .empty-state
+ .svg-content
+ = render 'shared/empty_states/icons/service_desk_setup.svg'
+ .text-content
+ %h4= _('Service Desk is enabled but not yet active')
+ %p
+ = _("You must set up incoming email before it becomes active.")
+ = link_to _('More information'), help_page_path('administration/incoming_email', anchor: 'set-it-up')
diff --git a/app/views/projects/issues/service_desk.html.haml b/app/views/projects/issues/service_desk.html.haml
new file mode 100644
index 00000000000..9b0b3ebc9e0
--- /dev/null
+++ b/app/views/projects/issues/service_desk.html.haml
@@ -0,0 +1,21 @@
+- @can_bulk_update = false
+
+- page_title _("Service Desk")
+
+- content_for :breadcrumbs_extra do
+ = render "projects/issues/nav_btns", show_export_button: false, show_rss_button: false
+
+- support_bot_attrs = UserSerializer.new.represent(User.support_bot).to_json
+
+%div{ class: "js-service-desk-issues service-desk-issues", data: { support_bot: support_bot_attrs } }
+ .top-area
+ = render 'shared/issuable/nav', type: :issues
+ .nav-controls.d-block.d-sm-none
+ = render "projects/issues/nav_btns", show_feed_buttons: false, show_import_button: false, show_export_button: false
+
+ - if @issues.present?
+ = render 'shared/issuable/search_bar', type: :issues
+ = render 'service_desk_info_content'
+
+ .issues-holder
+ = render 'projects/issues/issues', empty_state_path: 'service_desk_info_content'
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 68b43673d75..2a0dc5e30b9 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -11,7 +11,7 @@
- can_create_issue = show_new_issue_link?(@project)
= render_if_exists "projects/issues/alert_blocked", issue: @issue, current_user: current_user
-= render_if_exists "projects/issues/alert_moved_from_service_desk", issue: @issue
+= render "projects/issues/alert_moved_from_service_desk", issue: @issue
.detail-page-header
.detail-page-header-body
diff --git a/app/views/projects/sidebar/_issues_service_desk.html.haml b/app/views/projects/sidebar/_issues_service_desk.html.haml
new file mode 100644
index 00000000000..2730fe37f28
--- /dev/null
+++ b/app/views/projects/sidebar/_issues_service_desk.html.haml
@@ -0,0 +1,3 @@
+= nav_link(controller: :issues, action: :service_desk ) do
+ = link_to service_desk_project_issues_path(@project), title: 'Service Desk' do
+ = _('Service Desk')
diff --git a/app/views/shared/empty_states/icons/_service_desk_callout.svg b/app/views/shared/empty_states/icons/_service_desk_callout.svg
new file mode 100644
index 00000000000..2886388279e
--- /dev/null
+++ b/app/views/shared/empty_states/icons/_service_desk_callout.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82"><g fill="none" fill-rule="evenodd"><path fill="#F9F9F9" d="M2.12 42c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3C74.353 61.032 58.425 76 39 76 19.575 76 3.647 61.032 2.12 42z"/><path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/><rect width="7" height="1" x="59" y="38" fill="#E1DBF2" rx=".5"/><path fill="#6B4FBB" d="M60.5 42a3.5 3.5 0 0 0 0-7v7z"/><rect width="7" height="1" x="12" y="38" fill="#E1DBF2" transform="matrix(-1 0 0 1 31 0)" rx=".5"/><path fill="#6B4FBB" d="M17.5 42a3.5 3.5 0 0 1 0-7v7z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M39 58c10.493 0 19-8.507 19-19s-8.507-19-19-19-19 8.507-19 19 8.507 19 19 19zm0 4c-12.703 0-23-10.297-23-23s10.297-23 23-23 23 10.297 23 23-10.297 23-23 23z"/><path fill="#6B4FBB" d="M35 56a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M26.5 40c0 4.143 3.355 7.5 7.494 7.5h10.012A7.497 7.497 0 0 0 51.5 40c0-4.143-3.355-7.5-7.494-7.5H33.994A7.497 7.497 0 0 0 26.5 40zm-3 0c0-5.799 4.698-10.5 10.494-10.5h10.012C49.802 29.5 54.5 34.2 54.5 40c0 5.799-4.698 10.5-10.494 10.5H33.994C28.198 50.5 23.5 45.8 23.5 40z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M35.255 42.406a1 1 0 1 1 1.872-.703 2.001 2.001 0 0 0 3.76-.038 1 1 0 1 1 1.886.665 4 4 0 0 1-7.518.076zM31.5 40a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm15 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/><path fill="#6B4FBB" d="M38 22h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2zm0 3h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2z" style="mix-blend-mode:multiply"/></g></svg> \ No newline at end of file
diff --git a/app/views/shared/empty_states/icons/_service_desk_empty_state.svg b/app/views/shared/empty_states/icons/_service_desk_empty_state.svg
new file mode 100644
index 00000000000..04c4870be07
--- /dev/null
+++ b/app/views/shared/empty_states/icons/_service_desk_empty_state.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="226" height="178" viewBox="0 0 226 178"><g fill="none" fill-rule="evenodd"><path fill="#EEE" fill-rule="nonzero" d="M109.496 165.895c2.06.108 4.113.134 6.158.08 1.104-.03 1.975-.95 1.945-2.055-.03-1.104-.95-1.975-2.055-1.945-1.94.053-3.886.028-5.84-.074-1.102-.057-2.043.79-2.1 1.893-.06 1.104.788 2.045 1.89 2.102zm18.408-1.245c2.02-.386 4.023-.853 6-1.4 1.066-.295 1.69-1.396 1.396-2.46-.295-1.066-1.397-1.69-2.46-1.396-1.875.52-3.772.96-5.686 1.327-1.085.208-1.797 1.255-1.59 2.34.207 1.085 1.255 1.797 2.34 1.59zm17.572-5.636c1.865-.86 3.696-1.795 5.486-2.803.962-.54 1.303-1.76.762-2.723-.542-.962-1.762-1.303-2.724-.762-1.697.955-3.43 1.84-5.2 2.656-1.002.464-1.44 1.652-.978 2.655.462 1.003 1.65 1.44 2.654.98zm44.342-74.897c-.142-2.056-.367-4.1-.674-6.127-.165-1.092-1.184-1.844-2.276-1.678-1.092.165-1.844 1.184-1.68 2.276.29 1.92.505 3.857.64 5.805.076 1.102 1.03 1.934 2.133 1.857 1.103-.076 1.934-1.03 1.858-2.133zm-3.505-18.144c-.632-1.956-1.343-3.884-2.13-5.78-.425-1.02-1.595-1.504-2.615-1.08-1.02.424-1.503 1.594-1.08 2.614.747 1.797 1.42 3.624 2.02 5.476.34 1.05 1.467 1.628 2.518 1.288 1.05-.34 1.627-1.466 1.287-2.517zm-7.754-16.73c-1.083-1.745-2.235-3.447-3.454-5.1-.655-.89-1.907-1.08-2.797-.423-.89.655-1.08 1.907-.424 2.796 1.155 1.568 2.247 3.18 3.273 4.835.58.94 1.814 1.23 2.753.647.938-.582 1.228-1.815.646-2.754zm-11.582-14.446c-1.468-1.437-2.993-2.814-4.572-4.128-.85-.708-2.11-.592-2.816.256-.707.85-.592 2.11.257 2.817 1.496 1.246 2.942 2.55 4.334 3.913.79.773 2.057.76 2.83-.03.772-.79.758-2.057-.032-2.83zm-101.422-4.91c-1.6 1.288-3.148 2.64-4.64 4.05-.802.76-.837 2.026-.078 2.828.76.802 2.025.837 2.827.078 1.415-1.338 2.882-2.62 4.4-3.84.86-.692.996-1.95.303-2.812-.692-.86-1.95-.996-2.812-.303zM52.7 43.062c-1.25 1.632-2.433 3.313-3.546 5.04-.6.93-.33 2.167.597 2.765.93.6 2.167.33 2.766-.597 1.055-1.637 2.176-3.23 3.36-4.777.67-.878.504-2.133-.374-2.804-.877-.672-2.132-.505-2.803.372zm-9.373 15.924c-.82 1.882-1.56 3.8-2.226 5.745-.356 1.047.2 2.183 1.247 2.54 1.045.358 2.182-.2 2.54-1.246.63-1.844 1.333-3.66 2.108-5.443.44-1.012-.023-2.19-1.036-2.63-1.014-.44-2.192.023-2.633 1.036zm-5.26 17.74c-.34 2.02-.6 4.058-.777 6.11-.096 1.102.72 2.07 1.82 2.167 1.1.095 2.07-.72 2.165-1.82.17-1.947.415-3.88.737-5.793.183-1.09-.552-2.12-1.64-2.304-1.09-.183-2.122.552-2.305 1.64zM74.87 155.55c1.772 1.038 3.585 2.005 5.437 2.897.995.48 2.19.062 2.67-.933.48-.995.062-2.19-.933-2.67-1.755-.845-3.473-1.76-5.152-2.745-.953-.56-2.178-.24-2.737.714-.558.954-.238 2.18.715 2.738zm16.97 7.34c1.966.578 3.96 1.078 5.975 1.498 1.082.225 2.14-.47 2.366-1.55.226-1.082-.468-2.14-1.55-2.366-1.91-.398-3.798-.872-5.662-1.42-1.06-.312-2.172.294-2.483 1.354-.312 1.06.294 2.17 1.354 2.483z"/><path fill="#F9F9F9" d="M2.12 130c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3-1.527 19.032-17.455 34-36.88 34-19.425 0-35.353-14.968-36.88-34z"/><path fill="#EEE" fill-rule="nonzero" d="M39 166c-21.54 0-39-17.46-39-39s17.46-39 39-39 39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 92 39 92 4 107.67 4 127s15.67 35 35 35z"/><path fill="#FDC4A8" fill-rule="nonzero" d="M53.925 116.226c-.277-.144-.59-.226-.925-.226H25c-.323 0-.628.076-.898.212l14.663 13.406c.39.357.99.348 1.37-.02l13.79-13.372zm1.075 4.53L42.92 132.47c-1.898 1.84-4.902 1.885-6.854.1L23 120.624V138c0 1.105.895 2 2 2h28c1.105 0 2-.895 2-2v-17.244zM25 112h28c3.314 0 6 2.686 6 6v20c0 3.314-2.686 6-6 6H25c-3.314 0-6-2.686-6-6v-20c0-3.314 2.686-6 6-6z"/><g><path fill="#F9F9F9" d="M150.12 131c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3-1.527 19.032-17.455 34-36.88 34-19.425 0-35.353-14.968-36.88-34z"/><path fill="#EEE" fill-rule="nonzero" d="M187 167c-21.54 0-39-17.46-39-39s17.46-39 39-39 39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35s-15.67-35-35-35-35 15.67-35 35 15.67 35 35 35z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M180.51 137H199c1.105 0 2-.895 2-2v-16c0-1.105-.895-2-2-2h-24c-1.105 0-2 .895-2 2v22.743l7.51-4.743zm1.157 4l-9.6 6.062c-.32.202-.69.31-1.067.31-1.105 0-2-.896-2-2V119c0-3.314 2.686-6 6-6h24c3.314 0 6 2.686 6 6v16c0 3.314-2.686 6-6 6h-17.333z"/><path fill="#6B4FBB" d="M180 129c-1.105 0-2-.895-2-2s.895-2 2-2 2 .895 2 2-.895 2-2 2zm7 0c-1.105 0-2-.895-2-2s.895-2 2-2 2 .895 2 2-.895 2-2 2zm7 0c-1.105 0-2-.895-2-2s.895-2 2-2 2 .895 2 2-.895 2-2 2z"/></g><g><path fill="#F9F9F9" d="M76.12 42c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3-1.527 19.032-17.455 34-36.88 34-19.425 0-35.353-14.968-36.88-34z"/><path fill="#EEE" fill-rule="nonzero" d="M113 78c-21.54 0-39-17.46-39-39S91.46 0 113 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S132.33 4 113 4 78 19.67 78 39s15.67 35 35 35z"/><g transform="translate(133 35)"><rect width="7" height="1" y="3" fill="#E1DBF2" rx=".5"/><path fill="#6B4FBB" d="M1.5 7C3.433 7 5 5.433 5 3.5S3.433 0 1.5 0v7z"/></g><g transform="matrix(-1 0 0 1 93 35)"><rect width="7" height="1" y="3" fill="#E1DBF2" rx=".5"/><path fill="#6B4FBB" d="M1.5 7C3.433 7 5 5.433 5 3.5S3.433 0 1.5 0v7z"/></g><path fill="#E1DBF1" fill-rule="nonzero" d="M113 58c10.493 0 19-8.507 19-19s-8.507-19-19-19-19 8.507-19 19 8.507 19 19 19zm0 4c-12.703 0-23-10.297-23-23s10.297-23 23-23 23 10.297 23 23-10.297 23-23 23z"/><path fill="#6B4FBB" d="M109 56c-.552 0-1-.448-1-1s.448-1 1-1 1 .448 1 1-.448 1-1 1zm4 0c-.552 0-1-.448-1-1s.448-1 1-1 1 .448 1 1-.448 1-1 1zm4 0c-.552 0-1-.448-1-1s.448-1 1-1 1 .448 1 1-.448 1-1 1z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M97.5 40c0-5.8 4.698-10.5 10.494-10.5h10.012c5.796 0 10.494 4.7 10.494 10.5s-4.698 10.5-10.494 10.5h-10.012C102.198 50.5 97.5 45.8 97.5 40zm3 0c0 4.143 3.355 7.5 7.494 7.5h10.012c4.14 0 7.494-3.358 7.494-7.5 0-4.143-3.355-7.5-7.494-7.5h-10.012c-4.14 0-7.494 3.358-7.494 7.5z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M109.255 42.406c-.195-.517.067-1.093.584-1.287.516-.196 1.093.066 1.287.583.29.774 1.033 1.297 1.873 1.297.855 0 1.608-.542 1.887-1.335.184-.52.755-.794 1.276-.61.52.183.794.754.61 1.275-.56 1.587-2.063 2.67-3.773 2.67-1.68 0-3.164-1.046-3.745-2.594zM105.5 40c-.828 0-1.5-.672-1.5-1.5s.672-1.5 1.5-1.5 1.5.672 1.5 1.5-.672 1.5-1.5 1.5zm15 0c-.828 0-1.5-.672-1.5-1.5s.672-1.5 1.5-1.5 1.5.672 1.5 1.5-.672 1.5-1.5 1.5z"/><path fill="#6B4FBB" d="M112 22h2c.552 0 1 .448 1 1s-.448 1-1 1h-2c-.552 0-1-.448-1-1s.448-1 1-1zm0 3h2c.552 0 1 .448 1 1s-.448 1-1 1h-2c-.552 0-1-.448-1-1s.448-1 1-1z" style="mix-blend-mode:multiply"/></g></g></svg>
diff --git a/app/views/shared/empty_states/icons/_service_desk_setup.svg b/app/views/shared/empty_states/icons/_service_desk_setup.svg
new file mode 100644
index 00000000000..bb791b58593
--- /dev/null
+++ b/app/views/shared/empty_states/icons/_service_desk_setup.svg
@@ -0,0 +1,39 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="430" height="167" viewBox="0 0 430 167">
+ <defs>
+ <rect id="a" width="81" height="4" x="96" y="88"/>
+ </defs>
+ <g fill="none" fill-rule="evenodd">
+ <g transform="translate(282 2)">
+ <rect width="40" height="4" x="25" y="86" fill="#DFDFDF" rx="2"/>
+ <rect width="22" height="4" y="86" fill="#DFDFDF" rx="2"/>
+ <path stroke="#DFDFDF" stroke-linecap="round" stroke-width="4" d="M63,88 C87.300529,88 107,68.300529 107,44 C107,19.699471 87.300529,0 63,0 C38.699471,0 19,19.699471 19,44 C19,55.4692579 23.3882741,65.9135795 30.5774088,73.7455512"/>
+ <path stroke="#DFDFDF" stroke-linecap="round" stroke-width="4" d="M52,142 L119,142 C133.911688,142 146,129.911688 146,115 C146,100.088312 133.911688,88 119,88 C104.088312,88 92,100.088312 92,115 C92,122.037954 94.6928046,128.446969 99.104319,133.252952" transform="matrix(1 0 0 -1 0 230)"/>
+ <path fill="#A7A7A7" d="M128 106C129.6569 106 131 107.343145 131 109L131 121C131 122.6569 129.6569 124 128 124L114.06641 124 109.250585 126.78325C108.250579 127.3612 107 126.63955 107 125.48455L107 109C107 107.343145 108.343147 106 110 106L128 106zM128 109L110 109 110 122.8852 113.26184 121 128 121 128 109zM114.5 113.5C115.32842 113.5 116 114.17158 116 115 116 115.82842 115.32842 116.5 114.5 116.5 113.67158 116.5 113 115.82842 113 115 113 114.17158 113.67158 113.5 114.5 113.5zM119 113.5C119.82842 113.5 120.5 114.17158 120.5 115 120.5 115.82842 119.82842 116.5 119 116.5 118.17158 116.5 117.5 115.82842 117.5 115 117.5 114.17158 118.17158 113.5 119 113.5zM123.5 113.5C124.32845 113.5 125 114.17158 125 115 125 115.82842 124.32845 116.5 123.5 116.5 122.67155 116.5 122 115.82842 122 115 122 114.17158 122.67155 113.5 123.5 113.5zM47 36C47 33.790862 48.790862 32 51 32L75 32C77.2092 32 79 33.790862 79 36L79 52C79 54.2092 77.2092 56 75 56L51 56C48.790862 56 47 54.2092 47 52L47 36zM51 36L75 36 75 36.0154 63.0079 42.93904 51 36.0063 51 36zM51 40.6251L51 52 75 52 75 40.6342 63.0079 47.55786 51 40.6251z"/>
+ </g>
+ <path stroke="#C2B7E6" stroke-linecap="round" stroke-width="4" d="M276.5,20 L276.5,165"/>
+ <use fill="#6E49CB" xlink:href="#a"/>
+ <use fill="#FFFFFF" fill-opacity=".6" xlink:href="#a"/>
+ <g transform="translate(172 40)">
+ <path fill="#6E49CB" fill-rule="nonzero" d="M64.5083266,2.16939521 C64.5598976,1.31008332 65.1555623,0.580183202 65.9870892,0.357376239 L67.0659897,0.0682857185 C67.8975166,-0.154521245 68.7783275,0.179758436 69.2526452,0.898158883 L71.0838835,3.67168101 C71.8604055,3.69835108 72.6253745,3.80075177 73.3696161,3.97339039 L75.8570965,1.76768551 C76.501214,1.19651341 77.4383928,1.10164098 78.1839968,1.53205032 L79.1513003,2.09052325 C79.8969043,2.52093259 80.2832521,3.38015574 80.1106561,4.22354464 L79.4443144,7.48050479 C79.9657604,8.03872555 80.4370489,8.65007844 80.8482561,9.30920953 L84.1658391,9.50834112 C85.025263,9.55988206 85.7551052,10.1555623 85.9779122,10.9870892 L86.2670027,12.0659897 C86.4898096,12.8975166 86.1555879,13.778312 85.4370754,14.2526597 L82.6635301,16.0839042 C82.6369953,16.86039 82.534498,17.6253848 82.3620332,18.3695798 L84.5676029,20.8570965 C85.1387232,21.5010208 85.2337633,22.4383618 84.8032767,23.1839864 L84.2448038,24.1512899 C83.8142654,24.8967214 82.9552293,25.2832262 82.111821,25.1106354 L78.8547318,24.4441212 C78.2965242,24.9657707 77.6852679,25.4370334 77.0260789,25.8482561 L76.8269473,29.1658391 C76.7754063,30.025263 76.1797261,30.7551052 75.3481992,30.9779122 L74.2692987,31.2670027 C73.4377718,31.4898096 72.5569764,31.1555879 72.0826287,30.4370754 L70.2513842,27.6635301 C69.4749563,27.6369798 68.7098843,27.5345032 67.9657472,27.3620229 L65.478263,29.5677909 C64.8341648,30.1389578 63.89683,30.2337892 63.1512826,29.8032819 L62.1839598,29.2448141 C61.4384642,28.8145 61.0520043,27.9552448 61.2245757,27.1118417 L61.8910899,23.8547525 C61.369479,23.2965346 60.898313,22.6852524 60.486955,22.0260996 L57.1693952,21.8269618 C56.3100833,21.7753908 55.5801832,21.1797261 55.3573762,20.3481992 L55.0682857,19.2692987 C54.8454788,18.4377718 55.1797584,17.5569609 55.8981589,17.0826432 L58.671681,15.2514049 C58.6983614,14.4749215 58.8007311,13.7098367 58.9733555,12.9656196 L56.7676172,10.4781688 C56.1964503,9.83407059 56.1015416,8.89675656 56.5319717,8.15122986 L57.0904394,7.18390704 C57.5208695,6.43838035 58.380086,6.05193078 59.2234504,6.22451259 L62.4805641,6.89104094 C63.0387487,6.36945971 63.6501081,5.89827293 64.3091888,5.48695498 L64.5083266,2.16939521 Z M72.7381966,23.3950508 C77.00585,22.2515365 79.5385651,17.8647453 78.3950508,13.5970918 C77.2515158,9.32936108 72.8647453,6.79672328 68.5970918,7.94023759 C64.3293611,9.0837726 61.7967026,13.4704658 62.9402376,17.7381966 C64.0837519,22.00585 68.4704658,24.5385858 72.7381966,23.3950508 Z"/>
+ <path fill="#EFEDF8" stroke="#6E49CB" stroke-width="4" d="M27.08832,20.735088 C27.63276,19.10172 29.16132,18 30.88304,18 L33.11696,18 C34.83868,18 36.36724,19.10172 36.91168,20.735088 L39.01368,27.04104 C40.5,27.49452 41.9248,28.08832 43.2732,28.80708 L49.2204,25.8336 C50.7604,25.0636 52.62,25.36544 53.8376,26.58288 L55.4172,28.16248 C56.6348,29.37992 56.9364,31.2398 56.1664,32.77976 L53.1932,38.7268 C53.9116,40.07512 54.5056,41.50012 54.9588,42.98632 L61.2648,45.08832 C62.8984,45.63276 64,47.16132 64,48.88304 L64,51.11696 C64,52.83868 62.8984,54.36724 61.2648,54.91168 L54.9588,57.01368 C54.5056,58.5 53.9116,59.9248 53.1932,61.2732 L56.1664,67.2204 C56.9364,68.76 56.6348,70.62 55.4172,71.8376 L53.8376,73.4172 C52.62,74.6344 50.7604,74.9364 49.2204,74.1664 L43.2732,71.1928 C41.9248,71.9116 40.5,72.5056 39.01368,72.9588 L36.91168,79.2648 C36.36724,80.8984 34.83868,82 33.11696,82 L30.88304,82 C29.16132,82 27.63276,80.8984 27.08832,79.2648 L24.98632,72.9588 C23.50012,72.5056 22.07516,71.9116 20.72688,71.1932 L14.77964,74.1668 C13.23968,74.9368 11.3798,74.6348 10.16236,73.4172 L8.58272,71.8376 C7.36528,70.6204 7.06348,68.7604 7.83344,67.2204 L10.80704,61.2732 C10.08832,59.9248 9.49452,58.5 9.04104,57.01368 L2.735088,54.91168 C1.10172,54.36724 0,52.83868 0,51.11696 L0,48.88304 C0,47.16132 1.10172,45.63276 2.735088,45.08832 L9.04104,42.98632 C9.49452,41.50008 10.08832,40.07504 10.80704,38.72668 L7.83348,32.77952 C7.06348,31.23956 7.36532,29.37968 8.58276,28.16224 L10.16236,26.5826 C11.3798,25.36516 13.23972,25.06336 14.77964,25.83332 L20.72688,28.80696 C22.0752,28.08828 23.50016,27.49448 24.98632,27.04104 L27.08832,20.735088 Z M32,66 C40.8364,66 48,58.8364 48,50 C48,41.16344 40.8364,34 32,34 C23.16344,34 16,41.16344 16,50 C16,58.8364 23.16344,66 32,66 Z"/>
+ <circle cx="32" cy="50" r="10" stroke="#6E49CB" stroke-linecap="round" stroke-width="2"/>
+ </g>
+ <g stroke="#FC6D26" transform="translate(123 78)">
+ <circle cx="12" cy="12" r="11" fill="#FFFFFF" stroke-width="2"/>
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M8,12.25 C9.8974359,14.0833333 10.8461538,15 10.8461538,15 C10.8461538,15 12.8974359,13 17,9"/>
+ </g>
+ <g transform="translate(0 40)">
+ <circle cx="50" cy="50" r="48" fill="#FFFFFF" stroke="#FC6D26" stroke-width="4"/>
+ <circle cx="21" cy="50" r="4" fill="#6E49CB"/>
+ <circle cx="79" cy="50" r="4" fill="#6E49CB"/>
+ <circle cx="50" cy="50" r="27" fill="#FFFFFF" stroke="#E1DBF1" stroke-width="4"/>
+ <rect width="38" height="24" x="31" y="38" fill="#FFFFFF" stroke="#E1DBF1" stroke-width="2" rx="12"/>
+ <circle cx="50" cy="69" r="2" fill="#6E49CB"/>
+ <circle cx="50" cy="69" r="2" fill="#6E49CB"/>
+ <circle cx="55" cy="69" r="1" fill="#6E49CB"/>
+ <circle cx="45" cy="69" r="1" fill="#6E49CB"/>
+ <path stroke="#6E49CB" stroke-linecap="round" stroke-width="2" d="M48 30L52 30M15 50L19 50M81 50L85 50M48 33.5L52 33.5"/>
+ <path fill="#6E49CB" d="M54.214 52.70154C54.9314 53.11584 55.177 54.0332 54.7628 54.7506 54.2804 55.5856 53.58722 56.2792 52.7524 56.7618 51.91758 57.2442 50.97058 57.4988 50.00632 57.5000085 49.04208 57.5012 48.09448 57.2488 47.25856 56.768 46.42264 56.2874 45.72774 55.5956 45.24358 54.7616 44.8276 54.0452 45.07118 53.12726 45.7876 52.71128 46.4443183 52.3299833 47.2704031 52.5028667 47.7239338 53.0861543L47.83798 53.2553C48.05804 53.63434 48.3739 53.94886 48.75388 54.1674 49.13384 54.3858 49.56456 54.5006 50.00286 54.5 50.44116 54.4994 50.8716 54.3838 51.25108 54.1644 51.554648 53.988944 51.8170384 53.7520992 52.0220822 53.470055L52.16486 53.2503C52.57918 52.53292 53.49658 52.28722 54.214 52.70154zM41 46C42.10456 46 43 46.89544 43 48 43 49.10456 42.10456 50 41 50 39.89544 50 39 49.10456 39 48 39 46.89544 39.89544 46 41 46zM59 46C60.1046 46 61 46.89544 61 48 61 49.10456 60.1046 50 59 50 57.89544 50 57 49.10456 57 48 57 46.89544 57.89544 46 59 46z"/>
+ </g>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_icon_service_desk.svg b/app/views/shared/icons/_icon_service_desk.svg
new file mode 100644
index 00000000000..2886388279e
--- /dev/null
+++ b/app/views/shared/icons/_icon_service_desk.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82"><g fill="none" fill-rule="evenodd"><path fill="#F9F9F9" d="M2.12 42c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3C74.353 61.032 58.425 76 39 76 19.575 76 3.647 61.032 2.12 42z"/><path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/><rect width="7" height="1" x="59" y="38" fill="#E1DBF2" rx=".5"/><path fill="#6B4FBB" d="M60.5 42a3.5 3.5 0 0 0 0-7v7z"/><rect width="7" height="1" x="12" y="38" fill="#E1DBF2" transform="matrix(-1 0 0 1 31 0)" rx=".5"/><path fill="#6B4FBB" d="M17.5 42a3.5 3.5 0 0 1 0-7v7z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M39 58c10.493 0 19-8.507 19-19s-8.507-19-19-19-19 8.507-19 19 8.507 19 19 19zm0 4c-12.703 0-23-10.297-23-23s10.297-23 23-23 23 10.297 23 23-10.297 23-23 23z"/><path fill="#6B4FBB" d="M35 56a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M26.5 40c0 4.143 3.355 7.5 7.494 7.5h10.012A7.497 7.497 0 0 0 51.5 40c0-4.143-3.355-7.5-7.494-7.5H33.994A7.497 7.497 0 0 0 26.5 40zm-3 0c0-5.799 4.698-10.5 10.494-10.5h10.012C49.802 29.5 54.5 34.2 54.5 40c0 5.799-4.698 10.5-10.494 10.5H33.994C28.198 50.5 23.5 45.8 23.5 40z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M35.255 42.406a1 1 0 1 1 1.872-.703 2.001 2.001 0 0 0 3.76-.038 1 1 0 1 1 1.886.665 4 4 0 0 1-7.518.076zM31.5 40a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm15 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/><path fill="#6B4FBB" d="M38 22h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2zm0 3h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2z" style="mix-blend-mode:multiply"/></g></svg> \ No newline at end of file
diff --git a/app/views/shared/promotions/_promote_servicedesk.html.haml b/app/views/shared/promotions/_promote_servicedesk.html.haml
new file mode 100644
index 00000000000..f7f65c34c75
--- /dev/null
+++ b/app/views/shared/promotions/_promote_servicedesk.html.haml
@@ -0,0 +1,13 @@
+.user-callout.promotion-callout.js-service-desk-callout#promote_service_desk{ data: { uid: 'promote_service_desk_dismissed' } }
+ .bordered-box.content-block
+ %button.btn.btn-default.close.js-close-callout{ type: 'button', 'aria-label' => 'Dismiss Service Desk promotion' }
+ = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
+ .svg-container
+ = custom_icon('icon_service_desk')
+ .user-callout-copy
+ -# haml-lint:disable NoPlainNodes
+ %h4
+ Improve customer support with GitLab Service Desk.
+ %p
+ GitLab Service Desk is a simple way to allow people to create issues in your GitLab instance without needing their own user account. It provides a unique email address for end users to create issues in a project, and replies can be sent either through the GitLab interface or by email. End users will only see the thread through email.
+ = link_to 'Read more', help_page_path('user/project/service_desk.md'), target: '_blank'
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index b079a114c71..e9dc138a850 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -5,7 +5,7 @@
---
- :name: authorized_project_update:authorized_project_update_project_create
:feature_category: :authentication_and_authorization
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
@@ -13,7 +13,7 @@
:tags: []
- :name: authorized_project_update:authorized_project_update_project_group_link_create
:feature_category: :authentication_and_authorization
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
@@ -21,7 +21,7 @@
:tags: []
- :name: authorized_project_update:authorized_project_update_user_refresh_over_user_range
:feature_category: :authentication_and_authorization
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
@@ -29,7 +29,7 @@
:tags: []
- :name: authorized_project_update:authorized_project_update_user_refresh_with_low_urgency
:feature_category: :authentication_and_authorization
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
@@ -37,87 +37,87 @@
:tags: []
- :name: auto_devops:auto_devops_disable
:feature_category: :auto_devops
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: auto_merge:auto_merge_process
:feature_category: :continuous_delivery
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 3
- :idempotent:
+ :idempotent:
:tags: []
- :name: chaos:chaos_cpu_spin
:feature_category: :not_owned
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: chaos:chaos_db_spin
:feature_category: :not_owned
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: chaos:chaos_kill
:feature_category: :not_owned
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: chaos:chaos_leak_mem
:feature_category: :not_owned
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: chaos:chaos_sleep
:feature_category: :not_owned
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: container_repository:cleanup_container_repository
:feature_category: :container_registry
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: container_repository:delete_container_repository
:feature_category: :container_registry
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:admin_email
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:authorized_project_update_periodic_recalculate
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
@@ -125,79 +125,79 @@
:tags: []
- :name: cronjob:ci_archive_traces_cron
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:container_expiration_policy
:feature_category: :container_registry
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:environments_auto_stop_cron
:feature_category: :continuous_delivery
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:expire_build_artifacts
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:gitlab_usage_ping
:feature_category: :collection
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:import_export_project_cleanup
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:import_stuck_project_import_jobs
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:issue_due_scheduler
:feature_category: :issue_tracking
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:jira_import_stuck_jira_import_jobs
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:metrics_dashboard_schedule_annotations_prune
:feature_category: :metrics
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
@@ -205,39 +205,39 @@
:tags: []
- :name: cronjob:namespaces_prune_aggregation_schedules
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:pages_domain_removal_cron
:feature_category: :pages
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:pages_domain_ssl_renewal_cron
:feature_category: :pages
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:pages_domain_verification_cron
:feature_category: :pages
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:partition_creation
:feature_category: :database
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
@@ -245,127 +245,127 @@
:tags: []
- :name: cronjob:personal_access_tokens_expiring
:feature_category: :authentication_and_authorization
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:pipeline_schedule
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:prune_old_events
:feature_category: :users
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:prune_web_hook_logs
:feature_category: :integrations
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:remove_expired_group_links
:feature_category: :authentication_and_authorization
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:remove_expired_members
:feature_category: :authentication_and_authorization
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:remove_unreferenced_lfs_objects
:feature_category: :git_lfs
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:repository_archive_cache
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:repository_check_dispatch
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:requests_profiles
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:schedule_migrate_external_diffs
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:stuck_ci_jobs
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:stuck_export_jobs
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:stuck_merge_jobs
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:trending_projects
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:update_container_registry_info
:feature_category: :container_registry
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
@@ -373,11 +373,11 @@
:tags: []
- :name: cronjob:users_create_statistics
:feature_category: :users
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: cronjob:x509_issuer_crl_check
:feature_category: :source_code_management
@@ -389,27 +389,27 @@
:tags: []
- :name: deployment:deployments_finished
:feature_category: :continuous_delivery
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 3
- :idempotent:
+ :idempotent:
:tags: []
- :name: deployment:deployments_forward_deployment
:feature_category: :continuous_delivery
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 3
- :idempotent:
+ :idempotent:
:tags: []
- :name: deployment:deployments_success
:feature_category: :continuous_delivery
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 3
- :idempotent:
+ :idempotent:
:tags: []
- :name: gcp_cluster:cluster_configure_istio
:feature_category: :kubernetes_management
@@ -417,7 +417,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: gcp_cluster:cluster_install_app
:feature_category: :kubernetes_management
@@ -425,7 +425,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: gcp_cluster:cluster_patch_app
:feature_category: :kubernetes_management
@@ -433,7 +433,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: gcp_cluster:cluster_provision
:feature_category: :kubernetes_management
@@ -441,15 +441,15 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: gcp_cluster:cluster_update_app
:feature_category: :kubernetes_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: gcp_cluster:cluster_upgrade_app
:feature_category: :kubernetes_management
@@ -457,7 +457,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: gcp_cluster:cluster_wait_for_app_installation
:feature_category: :kubernetes_management
@@ -465,15 +465,15 @@
:urgency: :low
:resource_boundary: :cpu
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: gcp_cluster:cluster_wait_for_app_update
:feature_category: :kubernetes_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: gcp_cluster:cluster_wait_for_ingress_ip_address
:feature_category: :kubernetes_management
@@ -481,23 +481,23 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: gcp_cluster:clusters_applications_activate_service
:feature_category: :kubernetes_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: gcp_cluster:clusters_applications_deactivate_service
:feature_category: :kubernetes_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: gcp_cluster:clusters_applications_uninstall
:feature_category: :kubernetes_management
@@ -505,7 +505,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: gcp_cluster:clusters_applications_wait_for_uninstall_app
:feature_category: :kubernetes_management
@@ -513,7 +513,7 @@
:urgency: :low
:resource_boundary: :cpu
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: gcp_cluster:clusters_cleanup_app
:feature_category: :kubernetes_management
@@ -521,7 +521,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: gcp_cluster:clusters_cleanup_project_namespace
:feature_category: :kubernetes_management
@@ -529,7 +529,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: gcp_cluster:clusters_cleanup_service_account
:feature_category: :kubernetes_management
@@ -537,7 +537,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: gcp_cluster:wait_for_cluster_creation
:feature_category: :kubernetes_management
@@ -545,7 +545,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: github_importer:github_import_import_diff_note
:feature_category: :importers
@@ -553,7 +553,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: github_importer:github_import_import_issue
:feature_category: :importers
@@ -561,7 +561,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: github_importer:github_import_import_lfs_object
:feature_category: :importers
@@ -569,7 +569,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: github_importer:github_import_import_note
:feature_category: :importers
@@ -577,7 +577,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: github_importer:github_import_import_pull_request
:feature_category: :importers
@@ -585,103 +585,103 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: github_importer:github_import_refresh_import_jid
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: github_importer:github_import_stage_finish_import
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: github_importer:github_import_stage_import_base_data
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: github_importer:github_import_stage_import_issues_and_diff_notes
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: github_importer:github_import_stage_import_lfs_objects
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: github_importer:github_import_stage_import_notes
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: github_importer:github_import_stage_import_pull_requests
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: github_importer:github_import_stage_import_repository
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: hashed_storage:hashed_storage_migrator
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: hashed_storage:hashed_storage_project_migrate
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: hashed_storage:hashed_storage_project_rollback
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: hashed_storage:hashed_storage_rollbacker
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: incident_management:clusters_applications_check_prometheus_health
:feature_category: :incident_management
@@ -701,159 +701,159 @@
:tags: []
- :name: incident_management:incident_management_process_alert
:feature_category: :incident_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: incident_management:incident_management_process_prometheus_alert
:feature_category: :incident_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: jira_importer:jira_import_advance_stage
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: jira_importer:jira_import_import_issue
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: jira_importer:jira_import_stage_finish_import
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: jira_importer:jira_import_stage_import_attachments
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: jira_importer:jira_import_stage_import_issues
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: jira_importer:jira_import_stage_import_labels
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: jira_importer:jira_import_stage_import_notes
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: jira_importer:jira_import_stage_start_import
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: mail_scheduler:mail_scheduler_issue_due
:feature_category: :issue_tracking
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: mail_scheduler:mail_scheduler_notification_service
:feature_category: :issue_tracking
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: object_pool:object_pool_create
:feature_category: :gitaly
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: object_pool:object_pool_destroy
:feature_category: :gitaly
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: object_pool:object_pool_join
:feature_category: :gitaly
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: object_pool:object_pool_schedule_join
:feature_category: :gitaly
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: object_storage:object_storage_background_move
:feature_category: :not_owned
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: object_storage:object_storage_migrate_uploads
:feature_category: :not_owned
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: pipeline_background:archive_trace
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: pipeline_background:ci_build_report_result
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
@@ -861,15 +861,15 @@
:tags: []
- :name: pipeline_background:ci_build_trace_chunk_flush
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: pipeline_background:ci_daily_build_group_report_results
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
@@ -893,7 +893,7 @@
:tags: []
- :name: pipeline_cache:expire_job_cache
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :unknown
:weight: 3
@@ -901,7 +901,7 @@
:tags: []
- :name: pipeline_cache:expire_pipeline_cache
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :cpu
:weight: 3
@@ -909,152 +909,152 @@
:tags: []
- :name: pipeline_creation:create_pipeline
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :cpu
:weight: 4
- :idempotent:
+ :idempotent:
:tags: []
- :name: pipeline_creation:run_pipeline_schedule
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 4
- :idempotent:
+ :idempotent:
:tags: []
- :name: pipeline_default:build_coverage
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 3
- :idempotent:
+ :idempotent:
:tags: []
- :name: pipeline_default:build_trace_sections
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 3
- :idempotent:
+ :idempotent:
:tags: []
- :name: pipeline_default:ci_create_cross_project_pipeline
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 3
- :idempotent:
+ :idempotent:
:tags: []
- :name: pipeline_default:ci_pipeline_bridge_status
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :cpu
:weight: 3
- :idempotent:
+ :idempotent:
:tags: []
- :name: pipeline_default:pipeline_metrics
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :unknown
:weight: 3
- :idempotent:
+ :idempotent:
:tags: []
- :name: pipeline_default:pipeline_notification
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :cpu
:weight: 3
- :idempotent:
+ :idempotent:
:tags: []
- :name: pipeline_default:pipeline_update_ci_ref_status
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :cpu
:weight: 3
- :idempotent:
+ :idempotent:
:tags: []
- :name: pipeline_hooks:build_hooks
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :unknown
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: pipeline_hooks:pipeline_hooks
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :cpu
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: pipeline_processing:build_finished
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :cpu
:weight: 5
- :idempotent:
+ :idempotent:
:tags:
- :requires_disk_io
- :name: pipeline_processing:build_queue
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :cpu
:weight: 5
- :idempotent:
+ :idempotent:
:tags: []
- :name: pipeline_processing:build_success
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :unknown
:weight: 5
- :idempotent:
+ :idempotent:
:tags: []
- :name: pipeline_processing:ci_build_prepare
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 5
- :idempotent:
+ :idempotent:
:tags: []
- :name: pipeline_processing:ci_build_schedule
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 5
- :idempotent:
+ :idempotent:
:tags: []
- :name: pipeline_processing:ci_resource_groups_assign_resource_from_resource_group
:feature_category: :continuous_delivery
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 5
- :idempotent:
+ :idempotent:
:tags: []
- :name: pipeline_processing:pipeline_process
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :unknown
:weight: 5
- :idempotent:
+ :idempotent:
:tags: []
- :name: pipeline_processing:pipeline_update
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :unknown
:weight: 5
@@ -1062,7 +1062,7 @@
:tags: []
- :name: pipeline_processing:stage_update
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :unknown
:weight: 5
@@ -1070,7 +1070,7 @@
:tags: []
- :name: pipeline_processing:update_head_pipeline_for_merge_request
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :cpu
:weight: 5
@@ -1078,71 +1078,71 @@
:tags: []
- :name: repository_check:repository_check_batch
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: repository_check:repository_check_clear
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: repository_check:repository_check_single_repository
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: todos_destroyer:todos_destroyer_confidential_issue
:feature_category: :issue_tracking
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: todos_destroyer:todos_destroyer_entity_leave
:feature_category: :issue_tracking
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: todos_destroyer:todos_destroyer_group_private
:feature_category: :issue_tracking
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: todos_destroyer:todos_destroyer_private_features
:feature_category: :issue_tracking
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: todos_destroyer:todos_destroyer_project_private
:feature_category: :issue_tracking
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: unassign_issuables:members_destroyer_unassign_issuables
:feature_category: :authentication_and_authorization
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
@@ -1150,7 +1150,7 @@
:tags: []
- :name: update_namespace_statistics:namespaces_root_statistics
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
@@ -1158,7 +1158,7 @@
:tags: []
- :name: update_namespace_statistics:namespaces_schedule_aggregation
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
@@ -1166,7 +1166,7 @@
:tags: []
- :name: authorized_keys
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :unknown
:weight: 2
@@ -1174,7 +1174,7 @@
:tags: []
- :name: authorized_projects
:feature_category: :authentication_and_authorization
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :unknown
:weight: 2
@@ -1182,11 +1182,11 @@
:tags: []
- :name: background_migration
:feature_category: :database
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :throttled
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: chat_notification
:feature_category: :chatops
@@ -1194,11 +1194,11 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: create_commit_signature
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
@@ -1206,91 +1206,91 @@
:tags: []
- :name: create_evidence
:feature_category: :release_evidence
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: create_note_diff_file
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: default
- :feature_category:
- :has_external_dependencies:
- :urgency:
- :resource_boundary:
+ :feature_category:
+ :has_external_dependencies:
+ :urgency:
+ :resource_boundary:
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: delete_diff_files
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: delete_merged_branches
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: delete_stored_files
:feature_category: :not_owned
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: delete_user
:feature_category: :authentication_and_authorization
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: design_management_new_version
:feature_category: :design_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :memory
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: detect_repository_languages
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: email_receiver
:feature_category: :issue_tracking
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :unknown
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: emails_on_push
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: error_tracking_issue_link
:feature_category: :error_tracking
@@ -1298,23 +1298,23 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: expire_build_instance_artifacts
:feature_category: :continuous_integration
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: export_csv
:feature_category: :issue_tracking
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: external_service_reactive_caching
:feature_category: :not_owned
@@ -1322,107 +1322,107 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: file_hook
:feature_category: :integrations
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: git_garbage_collect
:feature_category: :gitaly
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: github_import_advance_stage
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: gitlab_shell
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :unknown
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: group_destroy
:feature_category: :subgroups
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: group_export
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: group_import
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: import_issues_csv
:feature_category: :issue_tracking
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: invalid_gpg_signature_update
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: irker
:feature_category: :integrations
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: mailers
- :feature_category:
- :has_external_dependencies:
- :urgency:
- :resource_boundary:
+ :feature_category:
+ :has_external_dependencies:
+ :urgency:
+ :resource_boundary:
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: merge
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :unknown
:weight: 5
- :idempotent:
+ :idempotent:
:tags: []
- :name: merge_request_mergeability_check
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
@@ -1430,7 +1430,7 @@
:tags: []
- :name: metrics_dashboard_prune_old_annotations
:feature_category: :metrics
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
@@ -1438,87 +1438,87 @@
:tags: []
- :name: migrate_external_diffs
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: namespaceless_project_destroy
:feature_category: :authentication_and_authorization
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: new_issue
:feature_category: :issue_tracking
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :cpu
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: new_merge_request
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :cpu
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: new_note
:feature_category: :issue_tracking
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :cpu
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: pages
:feature_category: :pages
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: pages_domain_ssl_renewal
:feature_category: :pages
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: pages_domain_verification
:feature_category: :pages
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: phabricator_import_import_tasks
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: post_receive
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :cpu
:weight: 5
- :idempotent:
+ :idempotent:
:tags: []
- :name: process_commit
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :unknown
:weight: 3
@@ -1526,35 +1526,35 @@
:tags: []
- :name: project_cache
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: project_daily_statistics
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: project_destroy
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: project_export
:feature_category: :importers
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :throttled
:resource_boundary: :memory
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: project_service
:feature_category: :integrations
@@ -1562,11 +1562,11 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: project_update_repository_storage
:feature_category: :gitaly
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :throttled
:resource_boundary: :unknown
:weight: 1
@@ -1574,7 +1574,7 @@
:tags: []
- :name: prometheus_create_default_alerts
:feature_category: :incident_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :unknown
:weight: 1
@@ -1582,7 +1582,7 @@
:tags: []
- :name: propagate_integration
:feature_category: :integrations
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
@@ -1590,51 +1590,51 @@
:tags: []
- :name: propagate_service_template
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: reactive_caching
:feature_category: :not_owned
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: rebase
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: remote_mirror_notification
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: repository_cleanup
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: repository_fork
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: repository_import
:feature_category: :importers
@@ -1642,15 +1642,15 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: repository_remove_remote
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: repository_update_remote_mirror
:feature_category: :source_code_management
@@ -1658,23 +1658,23 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: self_monitoring_project_create
:feature_category: :metrics
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: self_monitoring_project_delete
:feature_category: :metrics
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
- :idempotent:
+ :idempotent:
:tags: []
- :name: service_desk_email_receiver
:feature_category: :issue_tracking
@@ -1686,23 +1686,23 @@
:tags: []
- :name: system_hook_push
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: update_external_pull_requests
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 3
- :idempotent:
+ :idempotent:
:tags: []
- :name: update_highest_role
:feature_category: :authentication_and_authorization
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :unknown
:weight: 2
@@ -1710,27 +1710,27 @@
:tags: []
- :name: update_merge_requests
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :high
:resource_boundary: :cpu
:weight: 3
- :idempotent:
+ :idempotent:
:tags: []
- :name: update_project_statistics
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: upload_checksum
:feature_category: :geo_replication
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: web_hook
:feature_category: :integrations
@@ -1738,11 +1738,11 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent:
:tags: []
- :name: x509_certificate_revoke
:feature_category: :source_code_management
- :has_external_dependencies:
+ :has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
diff --git a/changelogs/unreleased/202159-open-fork.yml b/changelogs/unreleased/202159-open-fork.yml
new file mode 100644
index 00000000000..9be66cc8deb
--- /dev/null
+++ b/changelogs/unreleased/202159-open-fork.yml
@@ -0,0 +1,6 @@
+---
+title: If a user does not have write access to repo, but a fork exists, the Web IDE
+ button should take them to the fork
+merge_request: 36548
+author:
+type: added
diff --git a/changelogs/unreleased/216022-use-pod-label.yml b/changelogs/unreleased/216022-use-pod-label.yml
new file mode 100644
index 00000000000..76ad5bc8430
--- /dev/null
+++ b/changelogs/unreleased/216022-use-pod-label.yml
@@ -0,0 +1,5 @@
+---
+title: 'Fix the default metrics dashboard to work on K8s versions 1.12 to 1.16'
+merge_request: 36863
+author:
+type: fixed
diff --git a/changelogs/unreleased/217803-follow-up-from-resolve-distribute-daily-cron-schedules-out-over-th.yml b/changelogs/unreleased/217803-follow-up-from-resolve-distribute-daily-cron-schedules-out-over-th.yml
new file mode 100644
index 00000000000..6f2106fdd81
--- /dev/null
+++ b/changelogs/unreleased/217803-follow-up-from-resolve-distribute-daily-cron-schedules-out-over-th.yml
@@ -0,0 +1,5 @@
+---
+title: Fix UI quirks with pipeline schedule cron options
+merge_request: 36471
+author:
+type: changed
diff --git a/changelogs/unreleased/36574-custom-renderer-identifiers-instances.yml b/changelogs/unreleased/36574-custom-renderer-identifiers-instances.yml
new file mode 100644
index 00000000000..b5173375cf6
--- /dev/null
+++ b/changelogs/unreleased/36574-custom-renderer-identifiers-instances.yml
@@ -0,0 +1,5 @@
+---
+title: Add a custom HTML renderer to the Static Site Editor for markdown identifier instance syntax
+merge_request: 36574
+author:
+type: added
diff --git a/changelogs/unreleased/fix-ci-variables-regression.yml b/changelogs/unreleased/fix-ci-variables-regression.yml
new file mode 100644
index 00000000000..9aefac9ec63
--- /dev/null
+++ b/changelogs/unreleased/fix-ci-variables-regression.yml
@@ -0,0 +1,5 @@
+---
+title: Fix not being able to add more than one CI variable through the UI
+merge_request: 37001
+author:
+type: fixed
diff --git a/changelogs/unreleased/issue_22856_fe.yml b/changelogs/unreleased/issue_22856_fe.yml
new file mode 100644
index 00000000000..dcd1147bbba
--- /dev/null
+++ b/changelogs/unreleased/issue_22856_fe.yml
@@ -0,0 +1,5 @@
+---
+title: Move service desk feature to core
+merge_request: 36613
+author:
+type: changed
diff --git a/changelogs/unreleased/rp-use-stable-sort-in-sorter.yml b/changelogs/unreleased/rp-use-stable-sort-in-sorter.yml
new file mode 100644
index 00000000000..54b0e21950a
--- /dev/null
+++ b/changelogs/unreleased/rp-use-stable-sort-in-sorter.yml
@@ -0,0 +1,5 @@
+---
+title: Sort metrics dashboard panels and groups using a stable sort
+merge_request: 36278
+author:
+type: fixed
diff --git a/changelogs/unreleased/short-url-for-custom-metrics-dashboards.yml b/changelogs/unreleased/short-url-for-custom-metrics-dashboards.yml
new file mode 100644
index 00000000000..7e79f0aa0fd
--- /dev/null
+++ b/changelogs/unreleased/short-url-for-custom-metrics-dashboards.yml
@@ -0,0 +1,5 @@
+---
+title: Support short urls for custom metrics dashboards
+merge_request: 36740
+author:
+type: added
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index 762357880e5..b7432c4cbe6 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -586,6 +586,9 @@ Gitlab.ee do
Settings.cron_jobs['iterations_update_status_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['iterations_update_status_worker']['cron'] ||= '5 0 * * *'
Settings.cron_jobs['iterations_update_status_worker']['job_class'] = 'IterationsUpdateStatusWorker'
+ Settings.cron_jobs['vulnerability_statistics_schedule_worker'] ||= Settingslogic.new({})
+ Settings.cron_jobs['vulnerability_statistics_schedule_worker']['cron'] ||= '15 1 * * *'
+ Settings.cron_jobs['vulnerability_statistics_schedule_worker']['job_class'] = 'Vulnerabilities::Statistics::ScheduleWorker'
end
#
diff --git a/config/initializers/stackprof.rb b/config/initializers/stackprof.rb
index 85980987cb6..5497ff9a459 100644
--- a/config/initializers/stackprof.rb
+++ b/config/initializers/stackprof.rb
@@ -62,6 +62,7 @@ if Gitlab::Utils.to_boolean(ENV['STACKPROF_ENABLED'].to_s)
)
StackProf.start(
+ mode: :cpu,
raw: Gitlab::Utils.to_boolean(ENV['STACKPROF_RAW'] || 'true'),
interval: ENV['STACKPROF_INTERVAL_US']&.to_i || 10_000
)
diff --git a/config/prometheus/common_metrics.yml b/config/prometheus/common_metrics.yml
index f0491df3db9..d9aaff12a4d 100644
--- a/config/prometheus/common_metrics.yml
+++ b/config/prometheus/common_metrics.yml
@@ -10,7 +10,9 @@ panel_groups:
weight: 4
metrics:
- id: system_metrics_kubernetes_container_memory_total
- query_range: 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-(.*)",namespace="{{kube_namespace}}"}) by (job)) without (job) /1024/1024/1024'
+ # Remove the second metric (after OR) when we drop support for K8s 1.13
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/229279
+ query_range: 'avg(sum(container_memory_usage_bytes{container!="POD",pod=~"^{{ci_environment_slug}}-(.*)",namespace="{{kube_namespace}}"}) by (job)) without (job) /1024/1024/1024 OR avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-(.*)",namespace="{{kube_namespace}}"}) by (job)) without (job) /1024/1024/1024'
label: Total (GB)
unit: GB
- title: "Core Usage (Total)"
@@ -19,7 +21,9 @@ panel_groups:
weight: 3
metrics:
- id: system_metrics_kubernetes_container_cores_total
- query_range: 'avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-(.*)",namespace="{{kube_namespace}}"}[15m])) by (job)) without (job)'
+ # Remove the second metric (after OR) when we drop support for K8s 1.13
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/229279
+ query_range: 'avg(sum(rate(container_cpu_usage_seconds_total{container!="POD",pod=~"^{{ci_environment_slug}}-(.*)",namespace="{{kube_namespace}}"}[15m])) by (job)) without (job) OR avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-(.*)",namespace="{{kube_namespace}}"}[15m])) by (job)) without (job)'
label: Total (cores)
unit: "cores"
- title: "Memory Usage (Pod average)"
@@ -28,7 +32,9 @@ panel_groups:
weight: 2
metrics:
- id: system_metrics_kubernetes_container_memory_average
- query_range: 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}) without (job)) /1024/1024'
+ # Remove the second metric (after OR) when we drop support for K8s 1.13
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/229279
+ query_range: 'avg(sum(container_memory_usage_bytes{container!="POD",pod=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container!="POD",pod=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}) without (job)) /1024/1024 OR avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}) without (job)) /1024/1024'
label: Pod average (MB)
unit: MB
- title: "Canary: Memory Usage (Pod Average)"
@@ -37,7 +43,9 @@ panel_groups:
weight: 2
metrics:
- id: system_metrics_kubernetes_container_memory_average_canary
- query_range: 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-canary-(.*)",namespace="{{kube_namespace}}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-canary-(.*)",namespace="{{kube_namespace}}"}) without (job)) /1024/1024'
+ # Remove the second metric (after OR) when we drop support for K8s 1.13
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/229279
+ query_range: 'avg(sum(container_memory_usage_bytes{container!="POD",pod=~"^{{ci_environment_slug}}-canary-(.*)",namespace="{{kube_namespace}}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container!="POD",pod=~"^{{ci_environment_slug}}-canary-(.*)",namespace="{{kube_namespace}}"}) without (job)) /1024/1024 OR avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-canary-(.*)",namespace="{{kube_namespace}}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-canary-(.*)",namespace="{{kube_namespace}}"}) without (job)) /1024/1024'
label: Pod average (MB)
unit: MB
track: canary
@@ -47,7 +55,9 @@ panel_groups:
weight: 1
metrics:
- id: system_metrics_kubernetes_container_core_usage
- query_range: 'avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}[15m])) by (job)) without (job) / count(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}[15m])) by (pod_name))'
+ # Remove the second metric (after OR) when we drop support for K8s 1.13
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/229279
+ query_range: 'avg(sum(rate(container_cpu_usage_seconds_total{container!="POD",pod=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}[15m])) by (job)) without (job) / count(sum(rate(container_cpu_usage_seconds_total{container!="POD",pod=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}[15m])) by (pod)) OR avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}[15m])) by (job)) without (job) / count(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}[15m])) by (pod_name))'
label: Pod average (cores)
unit: "cores"
- title: "Canary: Core Usage (Pod Average)"
@@ -56,7 +66,9 @@ panel_groups:
weight: 1
metrics:
- id: system_metrics_kubernetes_container_core_usage_canary
- query_range: 'avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-canary-(.*)",namespace="{{kube_namespace}}"}[15m])) by (job)) without (job) / count(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-canary-(.*)",namespace="{{kube_namespace}}"}[15m])) by (pod_name))'
+ # Remove the second metric (after OR) when we drop support for K8s 1.13
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/229279
+ query_range: 'avg(sum(rate(container_cpu_usage_seconds_total{container!="POD",pod=~"^{{ci_environment_slug}}-canary-(.*)",namespace="{{kube_namespace}}"}[15m])) by (job)) without (job) / count(sum(rate(container_cpu_usage_seconds_total{container!="POD",pod=~"^{{ci_environment_slug}}-canary-(.*)",namespace="{{kube_namespace}}"}[15m])) by (pod)) OR avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-canary-(.*)",namespace="{{kube_namespace}}"}[15m])) by (job)) without (job) / count(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-canary-(.*)",namespace="{{kube_namespace}}"}[15m])) by (pod_name))'
label: Pod average (cores)
unit: "cores"
track: canary
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index 0449ad1b8a5..8dd60bcd65c 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -272,6 +272,8 @@
- 1
- - upload_checksum
- 1
+- - vulnerabilities_statistics_adjustment
+ - 1
- - vulnerability_exports_export
- 1
- - vulnerability_exports_export_deletion
diff --git a/doc/administration/geo/replication/docker_registry.md b/doc/administration/geo/replication/docker_registry.md
index bea6528dc9b..c34732cba67 100644
--- a/doc/administration/geo/replication/docker_registry.md
+++ b/doc/administration/geo/replication/docker_registry.md
@@ -18,7 +18,7 @@ Registry on the **primary** node, you can use the same storage for a **secondary
Docker Registry as well. For more information, read the
[Load balancing considerations](https://docs.docker.com/registry/deploying/#load-balancing-considerations)
when deploying the Registry, and how to set up the storage driver for GitLab's
-integrated [Container Registry](../../packages/container_registry.md#container-registry-storage-driver).
+integrated [Container Registry](../../packages/container_registry.md#use-object-storage).
## Replicating Docker Registry
diff --git a/doc/administration/object_storage.md b/doc/administration/object_storage.md
index 0ce2d0b3719..770bef3f331 100644
--- a/doc/administration/object_storage.md
+++ b/doc/administration/object_storage.md
@@ -422,7 +422,7 @@ supported by consolidated configuration form, refer to the following guides:
| [Job artifacts](job_artifacts.md#using-object-storage) and [incremental logging](job_logs.md#new-incremental-logging-architecture) | Yes |
| [LFS objects](lfs/index.md#storing-lfs-objects-in-remote-object-storage) | Yes |
| [Uploads](uploads.md#using-object-storage-core-only) | Yes |
-| [Container Registry](packages/container_registry.md#container-registry-storage-driver) (optional feature) | No |
+| [Container Registry](packages/container_registry.md#use-object-storage) (optional feature) | No |
| [Merge request diffs](merge_request_diffs.md#using-object-storage) | Yes |
| [Mattermost](https://docs.mattermost.com/administration/config-settings.html#file-storage)| No |
| [Packages](packages/index.md#using-object-storage) (optional feature) **(PREMIUM ONLY)** | Yes |
diff --git a/doc/administration/packages/container_registry.md b/doc/administration/packages/container_registry.md
index 169d02fe13d..fd3e77555fd 100644
--- a/doc/administration/packages/container_registry.md
+++ b/doc/administration/packages/container_registry.md
@@ -76,7 +76,7 @@ where:
| `port` | The port under which the external Registry domain will listen on. |
| `api_url` | The internal API URL under which the Registry is exposed to. It defaults to `http://localhost:5000`. |
| `key` | The private key location that is a pair of Registry's `rootcertbundle`. Read the [token auth configuration documentation](https://docs.docker.com/registry/configuration/#token). |
-| `path` | This should be the same directory like specified in Registry's `rootdirectory`. Read the [storage configuration documentation](https://docs.docker.com/registry/configuration/#storage). This path needs to be readable by the GitLab user, the web-server user and the Registry user. Read more in [#container-registry-storage-path](#container-registry-storage-path). |
+| `path` | This should be the same directory like specified in Registry's `rootdirectory`. Read the [storage configuration documentation](https://docs.docker.com/registry/configuration/#storage). This path needs to be readable by the GitLab user, the web-server user and the Registry user. Read more in [#configure-storage-for-the-container-registry](#configure-storage-for-the-container-registry). |
| `issuer` | This should be the same value as configured in Registry's `issuer`. Read the [token auth configuration documentation](https://docs.docker.com/registry/configuration/#token). |
NOTE: **Note:**
@@ -313,11 +313,28 @@ the Container Registry by themselves, follow the steps below.
1. Save the file and [restart GitLab](../restart_gitlab.md#installations-from-source) for the changes to take effect.
-## Container Registry storage path
+## Configure storage for the Container Registry
-NOTE: **Note:**
-For configuring storage in the cloud instead of the filesystem, see the
-[storage driver configuration](#container-registry-storage-driver).
+You can configure the Container Registry to use various storage backends by
+configuring a storage driver. By default the GitLab Container Registry
+is configured to use the [filesystem driver](#use-filesystem)
+configuration.
+
+The different supported drivers are:
+
+| Driver | Description |
+|------------|-------------------------------------|
+| filesystem | Uses a path on the local filesystem |
+| Azure | Microsoft Azure Blob Storage |
+| gcs | Google Cloud Storage |
+| s3 | Amazon Simple Storage Service. Be sure to configure your storage bucket with the correct [S3 Permission Scopes](https://docs.docker.com/registry/storage-drivers/s3/#s3-permission-scopes). |
+| swift | OpenStack Swift Object Storage |
+| oss | Aliyun OSS |
+
+Read more about the individual driver's configuration options in the
+[Docker Registry docs](https://docs.docker.com/registry/configuration/#storage).
+
+### Use filesystem
If you want to store your images on the filesystem, you can change the storage
path for the Container Registry, follow the steps below.
@@ -327,7 +344,7 @@ This path is accessible to:
- The user running the Container Registry daemon.
- The user running GitLab.
-CAUTION: **Warning** You should confirm that all GitLab, Registry and web server users
+CAUTION: **Warning:** You should confirm that all GitLab, Registry and web server users
have access to this directory.
**Omnibus GitLab installations**
@@ -358,26 +375,10 @@ The default location where images are stored in source installations, is
1. Save the file and [restart GitLab](../restart_gitlab.md#installations-from-source) for the changes to take effect.
-### Container Registry storage driver
+### Use object storage
-You can configure the Container Registry to use a different storage backend by
-configuring a different storage driver. By default the GitLab Container Registry
-is configured to use the filesystem driver, which makes use of [storage path](#container-registry-storage-path)
-configuration.
-
-The different supported drivers are:
-
-| Driver | Description |
-|------------|-------------------------------------|
-| filesystem | Uses a path on the local filesystem |
-| Azure | Microsoft Azure Blob Storage |
-| gcs | Google Cloud Storage |
-| s3 | Amazon Simple Storage Service. Be sure to configure your storage bucket with the correct [S3 Permission Scopes](https://docs.docker.com/registry/storage-drivers/s3/#s3-permission-scopes). |
-| swift | OpenStack Swift Object Storage |
-| oss | Aliyun OSS |
-
-Read more about the individual driver's configuration options in the
-[Docker Registry docs](https://docs.docker.com/registry/configuration/#storage).
+If you want to store your images on object storage, you can change the storage
+driver for the Container Registry.
[Read more about using object storage with GitLab](../object_storage.md).
@@ -435,21 +436,43 @@ storage:
NOTE: **Note:**
`your-s3-bucket` should only be the name of a bucket that exists, and can't include subdirectories.
-**Migrate without downtime**
+#### Migrate to object storage without downtime
+
+To migrate storage without stopping the Container Registry, set the Container Registry
+to read-only mode. On large instances, this may require the Container Registry
+to be in read-only mode for a while. During this time,
+you can pull from the Container Registry, but you cannot push.
+
+1. Optional: To reduce the amount of data to be migrated, run the [garbage collection tool without downtime](#performing-garbage-collection-without-downtime).
+1. Copy initial data to your S3 bucket, for example with the AWS CLI [`cp`](https://awscli.amazonaws.com/v2/documentation/api/latest/reference/s3/cp.html)
+ or [`sync`](https://awscli.amazonaws.com/v2/documentation/api/latest/reference/s3/sync.html)
+ command. Make sure to keep the `docker` folder as the top-level folder inside the bucket.
+
+ ```shell
+ aws s3 sync registry s3://mybucket
+ ```
+
+1. For the changes to take effect,
+ [put the Container Registry in `read-only` mode](#performing-garbage-collection-without-downtime) and
+ [reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure).
+1. Sync any changes since the initial data load to your S3 bucket and delete files that exist in the destination bucket but not in the source:
+
+ ```shell
+ aws s3 sync registry s3://mybucket --delete
+ ```
-To migrate the data to AWS S3 without downtime:
+ DANGER: **Danger:**
+ The `--delete` flag will delete files that exist in the destination but not in the source.
+ Make sure not to swap the source and destination, or you will delete all data in the Registry.
-1. To reduce the amount of data to be migrated, run the [garbage collection tool without downtime](#performing-garbage-collection-without-downtime). Part of this process sets the registry to `read-only`.
-1. Copy the data to your AWS S3 bucket, for example with [AWS CLI's `cp`](https://docs.aws.amazon.com/cli/latest/reference/s3/cp.html) command.
1. Configure your registry to use the S3 bucket for storage.
-1. Put the registry back to `read-write`.
-1. [Reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.
+1. For the changes to take effect, set the Registry back to `read-write` mode and [reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure).
### Disable redirect for storage driver
By default, users accessing a registry configured with a remote backend are redirected to the default backend for the storage driver. For example, registries can be configured using the `s3` storage driver, which redirects requests to a remote S3 bucket to alleviate load on the GitLab server.
-However, this behavior is undesirable for registries used by internal hosts that usually can't access public servers. To disable redirects, set the `disable` flag to true as follows. This makes all traffic to always go through the Registry service. This results in improved security (less surface attack as the storage backend is not publicly accessible), but worse performance (all traffic is redirected via the service).
+However, this behavior is undesirable for registries used by internal hosts that usually can't access public servers. To disable redirects and [proxy download](../object_storage.md#proxy-download), set the `disable` flag to true as follows. This makes all traffic always go through the Registry service. This results in improved security (less surface attack as the storage backend is not publicly accessible), but worse performance (all traffic is redirected via the service).
**Omnibus GitLab installations**
@@ -779,13 +802,15 @@ that you have backed up all registry data.
> [Introduced](https://gitlab.com/gitlab-org/omnibus-gitlab/-/merge_requests/764) in GitLab 8.8.
-You can perform a garbage collection without stopping the Container Registry by setting
-it into a read-only mode and by not using the built-in command. During this time,
+You can perform garbage collection without stopping the Container Registry by putting
+it in read-only mode and by not using the built-in command. On large instances
+this could require Container Registry to be in read-only mode for a while.
+During this time,
you will be able to pull from the Container Registry, but you will not be able to
push.
NOTE: **Note:**
-By default, the [registry storage path](#container-registry-storage-path)
+By default, the [registry storage path](#configure-storage-for-the-container-registry)
is `/var/opt/gitlab/gitlab-rails/shared/registry`.
To enable the read-only mode:
diff --git a/doc/administration/reference_architectures/2k_users.md b/doc/administration/reference_architectures/2k_users.md
index 9b46fb1c303..c317cd24094 100644
--- a/doc/administration/reference_architectures/2k_users.md
+++ b/doc/administration/reference_architectures/2k_users.md
@@ -816,7 +816,7 @@ on the features you intend to use:
1. [Object storage for LFS objects](../lfs/index.md#storing-lfs-objects-in-remote-object-storage).
1. [Object storage for uploads](../uploads.md#using-object-storage-core-only).
1. [Object storage for merge request diffs](../merge_request_diffs.md#using-object-storage).
-1. [Object storage for Container Registry](../packages/container_registry.md#container-registry-storage-driver) (optional feature).
+1. [Object storage for Container Registry](../packages/container_registry.md#use-object-storage) (optional feature).
1. [Object storage for Mattermost](https://docs.mattermost.com/administration/config-settings.html#file-storage) (optional feature).
1. [Object storage for packages](../packages/index.md#using-object-storage) (optional feature). **(PREMIUM ONLY)**
1. [Object storage for Dependency Proxy](../packages/dependency_proxy.md#using-object-storage) (optional feature). **(PREMIUM ONLY)**
diff --git a/doc/development/secure_coding_guidelines.md b/doc/development/secure_coding_guidelines.md
index 912b8fbf043..65953620ce6 100644
--- a/doc/development/secure_coding_guidelines.md
+++ b/doc/development/secure_coding_guidelines.md
@@ -213,7 +213,7 @@ the mitigations for a new feature.
#### Feature-specific Mitigations
-For situations in which an allowlist or GitLab:HTTP cannot be used, it will be necessary to implement mitigations directly in the feature. It is best to validate the destination IP addresses themselves, not just domain names, as DNS can be controlled by the attacker. Below are a list of mitigations that should be implemented.
+For situtions in which an allowlist or GitLab:HTTP cannot be used, it will be necessary to implement mitigations directly in the feature. It is best to validate the destination IP addresses themselves, not just domain names, as DNS can be controlled by the attacker. Below are a list of mitigations that should be implemented.
**Important Note:** There are many tricks to bypass common SSRF validations. If feature-specific mitigations are necessary, they should be reviewed by the AppSec team, or a developer who has worked on SSRF mitigations previously.
@@ -278,6 +278,7 @@ For any and all input fields, ensure to define expectations on the type/format o
- Validate the [input size limits](https://youtu.be/2VFavqfDS6w?t=7582).
- Validate the input using an [allowlist approach](https://youtu.be/2VFavqfDS6w?t=7816) to only allow characters through which you are expecting to receive for the field.
- Input which fails validation should be **rejected**, and not sanitized.
+- When adding redirects or links to a user-controlled URL, ensure that the scheme is HTTP or HTTPS. Allowing other schemes like `javascript://` can lead to XSS and other security issues.
Note that denylists should be avoided, as it is near impossible to block all [variations of XSS](https://owasp.org/www-community/xss-filter-evasion-cheatsheet).
@@ -292,40 +293,60 @@ Once you've [determined when and where](#setting-expectations) the user submitte
### Additional info
-#### Mitigating XSS in Rails
+#### XSS mitigation and prevention in Rails
+
+By default, Rails automatically escapes strings when they are inserted into HTML templates. Avoid the
+methods used to keep Rails from escaping strings, especially those related to user-controlled values.
+Specifically, the following options are dangerous because they mark strings as trusted and safe:
+
+| Method | Avoid these options |
+|----------------------|-------------------------------|
+| HAML templates | `html_safe`, `raw`, `!=` |
+| Embedded Ruby (ERB) | `html_safe`, `raw`, `<%== %>` |
+In case you want to sanitize user-controlled values against XSS vulnerabilities, you can use
+[`ActionView::Helpers::SanitizeHelper`](https://api.rubyonrails.org/classes/ActionView/Helpers/SanitizeHelper.html).
+Calling `link_to` and `redirect_to` with user-controlled parameters can also lead to cross-site scripting.
+
+Do also sanitize and validate URL schemes.
+
+References:
- [XSS Defense in Rails](https://youtu.be/2VFavqfDS6w?t=2442)
- [XSS Defense with HAML](https://youtu.be/2VFavqfDS6w?t=2796)
- [Validating Untrusted URLs in Ruby](https://youtu.be/2VFavqfDS6w?t=3936)
- [RoR Model Validators](https://youtu.be/2VFavqfDS6w?t=7636)
+#### XSS mitigation and prevention in JavaScript and Vue
+
+- When updating the content of an HTML element using JavaScript, mark user-controlled values as `textContent` or `nodeValue` instead of `innerHTML`.
+- Avoid using `v-html` with user-controlled data, use [`v-safe-html`](https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/directives-safe-html-directive--default) instead.
+- Consider using [`gl-sprintf`](../../ee/development/i18n/externalization.md#interpolation) to interpolate translated strings securely.
+- Avoid `__()` with translations that contain user-controlled values.
+- When working with `postMessage`, ensure the `origin` of the message is allowlisted.
+- Consider using the [Safe Link Directive](https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/directives-safe-link-directive--default) to generate secure hyperlinks by default.
+
#### GitLab specific libraries for mitigating XSS
##### Vue
- [isSafeURL](https://gitlab.com/gitlab-org/gitlab/-/blob/v12.7.5-ee/app/assets/javascripts/lib/utils/url_utility.js#L190-207)
+- [GlSprintf](https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/utilities-sprintf--default)
#### Content Security Policy
- [Content Security Policy](https://www.youtube.com/watch?v=2VFavqfDS6w&t=12991s)
- [Use nonce-based Content Security Policy for inline JavaScript](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/65330)
-#### Free form input fields
-
-##### Sanitization
-
-- [HTML Sanitization](https://youtu.be/2VFavqfDS6w?t=5075)
-- [DOMPurify](https://youtu.be/2VFavqfDS6w?t=5381)
-
-##### `iframe` sandboxes
-
-- [iframe sandboxing](https://youtu.be/2VFavqfDS6w?t=7043)
+#### Free form input field
### Select examples of past XSS issues affecting GitLab
-- [Stored XSS in user status](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/55320)
+- [Stored XSS in user status](https://gitlab.com/gitlab-org/gitlab-foss/issues/55320)
+- [XSS vulnerability on custom project templates form](https://gitlab.com/gitlab-org/gitlab/issues/197302)
+- [Stored XSS in branch names](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/55320)
+- [Stored XSS in merge request pages](https://gitlab.com/gitlab-org/gitlab/-/issues/35096)
-### Developer Training
+### Internal Developer Training
- [Introduction to XSS](https://www.youtube.com/watch?v=PXR8PTojHmc&t=7785s)
- [Reflected XSS](https://youtu.be/2VFavqfDS6w?t=603s)
@@ -347,3 +368,29 @@ Once you've [determined when and where](#setting-expectations) the user submitte
- [RoR model validators](https://youtu.be/2VFavqfDS6w?t=7636)
- [Allowlist input validation](https://youtu.be/2VFavqfDS6w?t=7816)
- [Content Security Policy](https://www.youtube.com/watch?v=2VFavqfDS6w&t=12991s)
+
+## Path Traversal guidelines
+
+### Description
+
+Path Traversal vulnerabilities grant attackers access to arbitrary directories and files on the server that is executing an application, including data, code or credentials.
+
+### Impact
+
+Path Traversal attacks can lead to multiple critical and high severity issues, like arbitrary file read, remote code execution or information disclosure.
+
+### When to consider
+
+When working with user-controlled filenames/paths and filesystem APIs.
+
+### Mitigation and prevention
+
+In order to prevent Path Traversal vulnerabilities, user-controlled filenames or paths should be validated before being processed.
+
+- Comparing user input against an allowlist of allowed values or verifying that it only contains allowed characters.
+- After validating the user supplied input, it should be appended to the base directory and the path should be canonicalized using the filesystem API.
+
+#### GitLab specific validations
+
+- [`Gitlab::Utils.check_path_traversal`](https://gitlab.com/gitlab-org/security/gitlab/-/blob/master/lib/gitlab/utils.rb#L12-24) can be used to validate user input against Path Traversal vulnerabilities. Remember to add further validation when setting the `allowed_absolute` option to `true`.
+- [`file_path` API validator](https://gitlab.com/gitlab-org/security/gitlab/-/blob/master/lib/api/validations/validators/file_path.rb) to validate user input when working with the Grape gem.
diff --git a/doc/development/telemetry/index.md b/doc/development/telemetry/index.md
index 75f3f102932..0000e7e9e4f 100644
--- a/doc/development/telemetry/index.md
+++ b/doc/development/telemetry/index.md
@@ -45,9 +45,9 @@ Telemetry Guide:
More useful links:
- [Telemetry Direction](https://about.gitlab.com/direction/telemetry/)
-- [Data Analysis Process](https://about.gitlab.com/handbook/business-ops/data-team/#-data-analysis-process)
-- [Data for Product Managers](https://about.gitlab.com/handbook/business-ops/data-team/data-programs/data-for-product-managers/)
-- [Data Infrastructure](https://about.gitlab.com/handbook/business-ops/data-team/data-platform/data-infrastructure/)
+- [Data Analysis Process](https://about.gitlab.com/handbook/business-ops/data-team/#data-analysis-process/)
+- [Data for Product Managers](https://about.gitlab.com/handbook/business-ops/data-team/programs/data-for-product-managers/)
+- [Data Infrastructure](https://about.gitlab.com/handbook/business-ops/data-team/platform/infrastructure/)
## Our tracking tools
@@ -69,7 +69,7 @@ For more details, read the [Usage Ping](usage_ping.md) guide.
### Database import
-Database imports are full imports of data into GitLab's data warehouse. For GitLab.com, the PostgreSQL database is loaded into Snowflake data warehouse every 6 hours. For more details, see the [data team handbook](https://about.gitlab.com/handbook/business-ops/data-team/#extract-and-load).
+Database imports are full imports of data into GitLab's data warehouse. For GitLab.com, the PostgreSQL database is loaded into Snowflake data warehouse every 6 hours. For more details, see the [data team handbook](https://about.gitlab.com/handbook/business-ops/data-team/platform/#extract-and-load).
### Log system
@@ -145,7 +145,7 @@ The systems overview is a simplified diagram showing the interactions between Gi
For Telemetry purposes, GitLab Inc has three major components:
-1. [Data Infrastructure](https://about.gitlab.com/handbook/business-ops/data-team/data-platform/data-infrastructure/): This contains everything managed by our data team including Sisense Dashboards for visualization, Snowflake for Data Warehousing, incoming data sources such as PostgreSQL Pipeline and S3 Bucket, and lastly our data collectors [GitLab.com's Snowplow Collector](https://about.gitlab.com/handbook/engineering/infrastructure/library/snowplow/) and GitLab's Versions Application.
+1. [Data Infrastructure](https://about.gitlab.com/handbook/business-ops/data-team/platform/infrastructure/): This contains everything managed by our data team including Sisense Dashboards for visualization, Snowflake for Data Warehousing, incoming data sources such as PostgreSQL Pipeline and S3 Bucket, and lastly our data collectors [GitLab.com's Snowplow Collector](https://about.gitlab.com/handbook/engineering/infrastructure/library/snowplow/) and GitLab's Versions Application.
1. GitLab.com: This is the production GitLab application which is made up of a Client and Server. On the Client or browser side, a Snowplow JS Tracker (Frontend) is used to track client-side events. On the Server or application side, a Snowplow Ruby Tracker (Backend) is used to track server-side events. The server also contains Usage Ping which leverages a PostgreSQL database and a Redis in-memory data store to report on usage data. Lastly, the server also contains System Logs which are generated from running the GitLab application.
1. [Monitoring infrastructure](https://about.gitlab.com/handbook/engineering/monitoring/): This is the infrastructure used to ensure GitLab.com is operating smoothly. System Logs are sent from GitLab.com to our monitoring infrastructure and collected by a FluentD collector. From FluentD, logs are either sent to long term Google Cloud Services cold storage via Stackdriver, or, they are sent to our Elastic Cluster via Cloud Pub/Sub which can be explored in real-time using Kibana.
diff --git a/doc/development/telemetry/snowplow.md b/doc/development/telemetry/snowplow.md
index d3e7e960e41..f03742afe2d 100644
--- a/doc/development/telemetry/snowplow.md
+++ b/doc/development/telemetry/snowplow.md
@@ -16,9 +16,9 @@ For more information about Telemetry, see:
More useful links:
- [Telemetry Direction](https://about.gitlab.com/direction/telemetry/)
-- [Data Analysis Process](https://about.gitlab.com/handbook/business-ops/data-team/#-data-analysis-process)
-- [Data for Product Managers](https://about.gitlab.com/handbook/business-ops/data-team/data-programs/data-for-product-managers/)
-- [Data Infrastructure](https://about.gitlab.com/handbook/business-ops/data-team/data-platform/data-infrastructure/)
+- [Data Analysis Process](https://about.gitlab.com/handbook/business-ops/data-team/#data-analysis-process/)
+- [Data for Product Managers](https://about.gitlab.com/handbook/business-ops/data-team/programs/data-for-product-managers/)
+- [Data Infrastructure](https://about.gitlab.com/handbook/business-ops/data-team/platform/infrastructure/)
## What is Snowplow
diff --git a/doc/development/telemetry/usage_ping.md b/doc/development/telemetry/usage_ping.md
index 6ecd0f3c578..4824e46b9cd 100644
--- a/doc/development/telemetry/usage_ping.md
+++ b/doc/development/telemetry/usage_ping.md
@@ -21,9 +21,9 @@ For more information about Telemetry, see:
More useful links:
- [Telemetry Direction](https://about.gitlab.com/direction/telemetry/)
-- [Data Analysis Process](https://about.gitlab.com/handbook/business-ops/data-team/#-data-analysis-process)
-- [Data for Product Managers](https://about.gitlab.com/handbook/business-ops/data-team/data-programs/data-for-product-managers/)
-- [Data Infrastructure](https://about.gitlab.com/handbook/business-ops/data-team/data-platform/data-infrastructure/)
+- [Data Analysis Process](https://about.gitlab.com/handbook/business-ops/data-team/#data-analysis-process/)
+- [Data for Product Managers](https://about.gitlab.com/handbook/business-ops/data-team/programs/data-for-product-managers/)
+- [Data Infrastructure](https://about.gitlab.com/handbook/business-ops/data-team/platform/infrastructure/)
## What is Usage Ping?
diff --git a/doc/user/clusters/crossplane.md b/doc/user/clusters/crossplane.md
index e3c71f9f313..b30ebc57338 100644
--- a/doc/user/clusters/crossplane.md
+++ b/doc/user/clusters/crossplane.md
@@ -6,17 +6,15 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Crossplane configuration
-Once Crossplane [is installed](applications.md#crossplane), it must be configured for
-use.
-
+After [installing](applications.md#crossplane) Crossplane, you must configure it for use.
The process of configuring Crossplane includes:
-1. Configuring RBAC permissions.
-1. Configuring Crossplane with a cloud provider.
-1. Configure managed service access.
-1. Setting up Resource classes.
-1. Using Auto DevOps configuration options.
-1. Connect to the PostgreSQL instance.
+1. [Configure RBAC permissions](#configure-rbac-permissions).
+1. [Configure Crossplane with a cloud provider](#configure-crossplane-with-a-cloud-provider).
+1. [Configure managed service access](#configure-managed-service-access).
+1. [Set up Resource classes](#setting-up-resource-classes).
+1. Use [Auto DevOps configuration options](#auto-devops-configuration-options).
+1. [Connect to the PostgreSQL instance](#connect-to-the-postgresql-instance).
To allow Crossplane to provision cloud services such as PostgreSQL, the cloud provider
stack must be configured with a user account. For example:
@@ -24,14 +22,13 @@ stack must be configured with a user account. For example:
- A service account for GCP.
- An IAM user for AWS.
-Important notes:
+Some important notes:
-- This guide uses GCP as an example. However, the process for AWS and Azure will be
-similar.
-- Crossplane requires the Kubernetes cluster to be VPC native with Alias IPs enabled so
-that the IP address of the pods are routable within the GCP network.
+- This guide uses GCP as an example, but the processes for AWS and Azure are similar.
+- Crossplane requires the Kubernetes cluster to be VPC native with Alias IPs enabled,
+ so the IP addresses of the pods can be routed within the GCP network.
-First, we need to declare some environment variables with configuration that will be used throughout this guide:
+First, declare some environment variables with configuration for use in this guide:
```shell
export PROJECT_ID=crossplane-playground # the GCP project where all resources reside.
@@ -41,228 +38,223 @@ export REGION=us-central1 # the GCP region where the GKE cluster is provisioned.
## Configure RBAC permissions
-- For GitLab-managed clusters, RBAC is configured automatically.
-
-- For non-GitLab managed clusters, ensure that the service account for the token provided can manage resources in the `database.crossplane.io` API group:
-
- 1. Save the following YAML as `crossplane-database-role.yaml`:
-
- ```yaml
- apiVersion: rbac.authorization.k8s.io/v1
- kind: ClusterRole
- metadata:
- name: crossplane-database-role
- labels:
- rbac.authorization.k8s.io/aggregate-to-edit: "true"
- rules:
- - apiGroups:
- - database.crossplane.io
- resources:
- - postgresqlinstances
- verbs:
- - get
- - list
- - create
- - update
- - delete
- - patch
- - watch
- ```
-
- 1. Apply the cluster role to the cluster:
-
- ```shell
- kubectl apply -f crossplane-database-role.yaml
- ```
+For GitLab-managed clusters, role-based access control (RBAC) is configured automatically.
+
+For non-GitLab managed clusters, ensure that the service account for the token
+provided can manage resources in the `database.crossplane.io` API group:
+
+1. Save the following YAML as `crossplane-database-role.yaml`:
+
+ ```yaml
+ apiVersion: rbac.authorization.k8s.io/v1
+ kind: ClusterRole
+ metadata:
+ name: crossplane-database-role
+ labels:
+ rbac.authorization.k8s.io/aggregate-to-edit: "true"
+ rules:
+ - apiGroups:
+ - database.crossplane.io
+ resources:
+ - postgresqlinstances
+ verbs:
+ - get
+ - list
+ - create
+ - update
+ - delete
+ - patch
+ - watch
+ ```
+
+1. Apply the cluster role to the cluster:
+
+ ```shell
+ kubectl apply -f crossplane-database-role.yaml
+ ```
## Configure Crossplane with a cloud provider
See [Configure Your Cloud Provider Account](https://crossplane.github.io/docs/v0.4/cloud-providers.html)
to configure the installed cloud provider stack with a user account.
-Note that the Secret and the Provider resource referencing the Secret needs to be
+NOTE: **Note:**
+The Secret, and the Provider resource referencing the Secret, must be
applied to the `gitlab-managed-apps` namespace in the guide. Make sure you change that
while following the process.
-[Configure Providers](https://crossplane.github.io/docs/v0.4/cloud-providers.html)
-
## Configure Managed Service Access
-We need to configure connectivity between the PostgreSQL database and the GKE cluster.
-This can done by either:
+Next, configure connectivity between the PostgreSQL database and the GKE cluster
+by either:
- Using Crossplane as demonstrated below.
- Directly in the GCP console by
-[configuring private services access](https://cloud.google.com/vpc/docs/configure-private-services-access).
-Create a GlobalAddress and Connection resources:
-
-```shell
-cat > network.yaml <<EOF
----
-# gitlab-ad-globaladdress defines the IP range that will be allocated for cloud services connecting to the instances in the given Network.
-
-apiVersion: compute.gcp.crossplane.io/v1alpha3
-kind: GlobalAddress
-metadata:
- name: gitlab-ad-globaladdress
-spec:
- providerRef:
- name: gcp-provider
- reclaimPolicy: Delete
- name: gitlab-ad-globaladdress
- purpose: VPC_PEERING
- addressType: INTERNAL
- prefixLength: 16
- network: projects/$PROJECT_ID/global/networks/$NETWORK_NAME
----
-# gitlab-ad-connection is what allows cloud services to use the allocated GlobalAddress for communication. Behind
-# the scenes, it creates a VPC peering to the network that those service instances actually live.
-
-apiVersion: servicenetworking.gcp.crossplane.io/v1alpha3
-kind: Connection
-metadata:
- name: gitlab-ad-connection
-spec:
- providerRef:
- name: gcp-provider
- reclaimPolicy: Delete
- parent: services/servicenetworking.googleapis.com
- network: projects/$PROJECT_ID/global/networks/$NETWORK_NAME
- reservedPeeringRangeRefs:
- - name: gitlab-ad-globaladdress
-EOF
-```
-
-Apply the settings specified in the file with the following command:
-
-```shell
-kubectl apply -f network.yaml
-```
-
-You can verify creation of the network resources with the following commands.
-Verify that the status of both of these resources is ready and is synced.
-
-```shell
-kubectl describe connection.servicenetworking.gcp.crossplane.io gitlab-ad-connection
-kubectl describe globaladdress.compute.gcp.crossplane.io gitlab-ad-globaladdress
-```
+ [configuring private services access](https://cloud.google.com/vpc/docs/configure-private-services-access).
+
+1. Run the following command, which creates a `network.yaml` file, and configures
+ `GlobalAddress` and connection resources:
+
+ ```plaintext
+ cat > network.yaml <<EOF
+ ---
+ # gitlab-ad-globaladdress defines the IP range that will be allocated
+ # for cloud services connecting to the instances in the given Network.
+
+ apiVersion: compute.gcp.crossplane.io/v1alpha3
+ kind: GlobalAddress
+ metadata:
+ name: gitlab-ad-globaladdress
+ spec:
+ providerRef:
+ name: gcp-provider
+ reclaimPolicy: Delete
+ name: gitlab-ad-globaladdress
+ purpose: VPC_PEERING
+ addressType: INTERNAL
+ prefixLength: 16
+ network: projects/$PROJECT_ID/global/networks/$NETWORK_NAME
+ ---
+ # gitlab-ad-connection is what allows cloud services to use the allocated
+ # GlobalAddress for communication. Behind the scenes, it creates a VPC peering
+ # to the network that those service instances actually live.
+
+ apiVersion: servicenetworking.gcp.crossplane.io/v1alpha3
+ kind: Connection
+ metadata:
+ name: gitlab-ad-connection
+ spec:
+ providerRef:
+ name: gcp-provider
+ reclaimPolicy: Delete
+ parent: services/servicenetworking.googleapis.com
+ network: projects/$PROJECT_ID/global/networks/$NETWORK_NAME
+ reservedPeeringRangeRefs:
+ - name: gitlab-ad-globaladdress
+ EOF
+ ```
+
+1. Apply the settings specified in the file with the following command:
+
+ ```shell
+ kubectl apply -f network.yaml
+ ```
+
+1. Verify the creation of the network resources, and that both resources are ready and synced.
+
+ ```shell
+ kubectl describe connection.servicenetworking.gcp.crossplane.io gitlab-ad-connection
+ kubectl describe globaladdress.compute.gcp.crossplane.io gitlab-ad-globaladdress
+ ```
## Setting up Resource classes
-Resource classes are a way of defining a configuration for the required managed service. We will define the PostgreSQL Resource class
-
-- Define a `gcp-postgres-standard.yaml` resource class which contains
-
-1. A default CloudSQLInstanceClass.
-1. A CloudSQLInstanceClass with labels.
-
-```shell
-cat > gcp-postgres-standard.yaml <<EOF
-apiVersion: database.gcp.crossplane.io/v1beta1
-kind: CloudSQLInstanceClass
-metadata:
- name: cloudsqlinstancepostgresql-standard
- labels:
- gitlab-ad-demo: "true"
-specTemplate:
- writeConnectionSecretsToNamespace: gitlab-managed-apps
- forProvider:
- databaseVersion: POSTGRES_11_7
- region: $REGION
- settings:
- tier: db-custom-1-3840
- dataDiskType: PD_SSD
- dataDiskSizeGb: 10
- ipConfiguration:
- privateNetwork: projects/$PROJECT_ID/global/networks/$NETWORK_NAME
- # this should match the name of the provider created in the above step
- providerRef:
- name: gcp-provider
- reclaimPolicy: Delete
----
-apiVersion: database.gcp.crossplane.io/v1beta1
-kind: CloudSQLInstanceClass
-metadata:
- name: cloudsqlinstancepostgresql-standard-default
- annotations:
- resourceclass.crossplane.io/is-default-class: "true"
-specTemplate:
- writeConnectionSecretsToNamespace: gitlab-managed-apps
- forProvider:
- databaseVersion: POSTGRES_11_7
- region: $REGION
- settings:
- tier: db-custom-1-3840
- dataDiskType: PD_SSD
- dataDiskSizeGb: 10
- ipConfiguration:
- privateNetwork: projects/$PROJECT_ID/global/networks/$NETWORK_NAME
- # this should match the name of the provider created in the above step
- providerRef:
- name: gcp-provider
- reclaimPolicy: Delete
-EOF
-```
-
-Apply the resource class configuration with the following command:
-
-```shell
-kubectl apply -f gcp-postgres-standard.yaml
-```
-
-Verify creation of the Resource class with the following command:
-
-```shell
-kubectl get cloudsqlinstanceclasses
-```
-
-The Resource Classes allow you to define classes of service for a managed service. We could create another `CloudSQLInstanceClass` which requests for a larger or a faster disk. It could also request for a specific version of the database.
+Use resource classes to define a configuration for the required managed service.
+This example defines the PostgreSQL Resource class:
+
+1. Run the following command, which define a `gcp-postgres-standard.yaml` resource
+ class containing a default `CloudSQLInstanceClass` with labels:
+
+ ```plaintext
+ cat > gcp-postgres-standard.yaml <<EOF
+ apiVersion: database.gcp.crossplane.io/v1beta1
+ kind: CloudSQLInstanceClass
+ metadata:
+ name: cloudsqlinstancepostgresql-standard
+ labels:
+ gitlab-ad-demo: "true"
+ specTemplate:
+ writeConnectionSecretsToNamespace: gitlab-managed-apps
+ forProvider:
+ databaseVersion: POSTGRES_11_7
+ region: $REGION
+ settings:
+ tier: db-custom-1-3840
+ dataDiskType: PD_SSD
+ dataDiskSizeGb: 10
+ ipConfiguration:
+ privateNetwork: projects/$PROJECT_ID/global/networks/$NETWORK_NAME
+ # this should match the name of the provider created in the above step
+ providerRef:
+ name: gcp-provider
+ reclaimPolicy: Delete
+ ---
+ apiVersion: database.gcp.crossplane.io/v1beta1
+ kind: CloudSQLInstanceClass
+ metadata:
+ name: cloudsqlinstancepostgresql-standard-default
+ annotations:
+ resourceclass.crossplane.io/is-default-class: "true"
+ specTemplate:
+ writeConnectionSecretsToNamespace: gitlab-managed-apps
+ forProvider:
+ databaseVersion: POSTGRES_11_7
+ region: $REGION
+ settings:
+ tier: db-custom-1-3840
+ dataDiskType: PD_SSD
+ dataDiskSizeGb: 10
+ ipConfiguration:
+ privateNetwork: projects/$PROJECT_ID/global/networks/$NETWORK_NAME
+ # this should match the name of the provider created in the above step
+ providerRef:
+ name: gcp-provider
+ reclaimPolicy: Delete
+ EOF
+ ```
+
+1. Apply the resource class configuration with the following command:
+
+ ```shell
+ kubectl apply -f gcp-postgres-standard.yaml
+ ```
+
+1. Verify creation of the Resource class with the following command:
+
+ ```shell
+ kubectl get cloudsqlinstanceclasses
+ ```
+
+The Resource Classes allow you to define classes of service for a managed service.
+We could create another `CloudSQLInstanceClass` which requests for a larger or a
+faster disk. It could also request for a specific version of the database.
## Auto DevOps Configuration Options
-The Auto DevOps pipeline can be run with the following options:
-
-The Environment variables, `AUTO_DEVOPS_POSTGRES_MANAGED` and `AUTO_DEVOPS_POSTGRES_MANAGED_CLASS_SELECTOR` need to be set to provision PostgreSQL using Crossplane
-
-Alternatively, the following options can be overridden from the values for the Helm chart.
-
-- `postgres.managed` set to true which will select a default resource class.
- The resource class needs to be marked with the annotation
- `resourceclass.crossplane.io/is-default-class: "true"`. The CloudSQLInstanceClass
- `cloudsqlinstancepostgresql-standard-default` will be used to satisfy the claim.
-
-- `postgres.managed` set to `true` with `postgres.managedClassSelector`
- providing the resource class to choose based on labels. In this case, the
- value of `postgres.managedClassSelector.matchLabels.gitlab-ad-demo="true"`
- will select the CloudSQLInstance class `cloudsqlinstancepostgresql-standard`
- to satisfy the claim request.
+You can run the Auto DevOps pipeline with either of the following options:
+
+- Setting the Environment variables `AUTO_DEVOPS_POSTGRES_MANAGED` and
+ `AUTO_DEVOPS_POSTGRES_MANAGED_CLASS_SELECTOR` to provision PostgreSQL using Crossplane.
+- Overriding values for the Helm chart:
+ - Set `postgres.managed` to `true`, which selects a default resource class.
+ Mark the resource class with the annotation
+ `resourceclass.crossplane.io/is-default-class: "true"`. The CloudSQLInstanceClass
+ `cloudsqlinstancepostgresql-standard-default` is used to satisfy the claim.
+ - Set `postgres.managed` to `true` with `postgres.managedClassSelector`
+ providing the resource class to choose, based on labels. In this case, the
+ value of `postgres.managedClassSelector.matchLabels.gitlab-ad-demo="true"`
+ selects the CloudSQLInstance class `cloudsqlinstancepostgresql-standard`
+ to satisfy the claim request.
The Auto DevOps pipeline should provision a PostgresqlInstance when it runs successfully.
-Verify creation of the PostgreSQL Instance.
+To verify the PostgreSQL instance was created, run this command. When the `STATUS`
+field of the PostgresqlInstance changes to `BOUND`, it's successfully provisioned:
```shell
-kubectl get postgresqlinstance
-```
+$ kubectl get postgresqlinstance
-Sample Output: The `STATUS` field of the PostgresqlInstance transitions to `BOUND` when it is successfully provisioned.
-
-```plaintext
NAME STATUS CLASS-KIND CLASS-NAME RESOURCE-KIND RESOURCE-NAME AGE
staging-test8 Bound CloudSQLInstanceClass cloudsqlinstancepostgresql-standard CloudSQLInstance xp-ad-demo-24-staging-staging-test8-jj55c 9m
```
-The endpoint of the PostgreSQL instance, and the user credentials, are present in a secret called `app-postgres` within the same project namespace.
-
-Verify the secret with the database information is created with the following command:
+The endpoint of the PostgreSQL instance, and the user credentials, are present in
+a secret called `app-postgres` within the same project namespace. You can verify the
+secret with the following command:
```shell
-kubectl describe secret app-postgres
-```
-
-Sample Output:
+$ kubectl describe secret app-postgres
-```plaintext
Name: app-postgres
Namespace: xp-ad-demo-24-staging
Labels: <none>
diff --git a/doc/user/project/merge_requests/img/merge_when_pipeline_succeeds_only_if_succeeds_msg.png b/doc/user/project/merge_requests/img/merge_when_pipeline_succeeds_only_if_succeeds_msg.png
deleted file mode 100644
index 761690d1e0c..00000000000
--- a/doc/user/project/merge_requests/img/merge_when_pipeline_succeeds_only_if_succeeds_msg.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/merge_requests/merge_when_pipeline_succeeds.md b/doc/user/project/merge_requests/merge_when_pipeline_succeeds.md
index d45ccdc9be9..7d90c9f3cd7 100644
--- a/doc/user/project/merge_requests/merge_when_pipeline_succeeds.md
+++ b/doc/user/project/merge_requests/merge_when_pipeline_succeeds.md
@@ -1,36 +1,38 @@
---
+stage: Create
+group: Source Code
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: reference, concepts
---
# Merge when pipeline succeeds
-When reviewing a merge request that looks ready to merge but still has one or
-more CI jobs running, you can set it to be merged automatically when the
-jobs pipeline succeeds. This way, you don't have to wait for the jobs to
+When reviewing a merge request that looks ready to merge but still has a
+pipeline running, you can set it to merge automatically when the
+pipeline succeeds. This way, you don't have to wait for the pipeline to
finish and remember to merge the request manually.
![Enable](img/merge_when_pipeline_succeeds_enable.png)
## How it works
-When you hit the "Merge When Pipeline Succeeds" button, the status of the merge
-request will be updated to represent the impending merge. If you cannot wait
-for the pipeline to succeed and want to merge immediately, this option is
-available in the dropdown menu on the right of the main button.
+When you click "Merge When Pipeline Succeeds", the status of the merge
+request is updated to show the impending merge. If you can't wait
+for the pipeline to succeed, you can choose **Merge immediately**
+in the dropdown menu on the right of the main button.
-Both team developers and the author of the merge request have the option to
-cancel the automatic merge if they find a reason why it shouldn't be merged
-after all.
+The author of the merge request and project members with developer permissions can
+cancel the automatic merge at any time before the pipeline finishes.
![Status](img/merge_when_pipeline_succeeds_status.png)
-When the pipeline succeeds, the merge request will automatically be merged.
+When the pipeline succeeds, the merge request is automatically merged.
When the pipeline fails, the author gets a chance to retry any failed jobs,
or to push new commits to fix the failure.
When the jobs are retried and succeed on the second try, the merge request
-will automatically be merged after all. When the merge request is updated with
-new commits, the automatic merge is automatically canceled to allow the new
+is automatically merged. When the merge request is updated with
+new commits, the automatic merge is canceled to allow the new
changes to be reviewed.
## Only allow merge requests to be merged if the pipeline succeeds
@@ -42,7 +44,7 @@ or if there are threads to be resolved. This works for both:
- Pipelines run from an [external CI integration](../integrations/overview.md#integrations-listing)
As a result, [disabling GitLab CI/CD pipelines](../../../ci/enable_or_disable_ci.md)
-will not disable this feature, as it will still be possible to use pipelines from external
+does not disable this feature, as it is possible to use pipelines from external
CI providers with this feature. To enable it, you must:
1. Navigate to your project's **Settings > General** page.
@@ -50,14 +52,40 @@ CI providers with this feature. To enable it, you must:
1. In the **Merge checks** subsection, select the **Pipelines must succeed** checkbox.
1. Press **Save** for the changes to take effect.
-NOTE: **Note:** This setting also prevents merge requests from being merged if there is no pipeline.
+This setting also prevents merge requests from being merged if there is no pipeline.
-![Pipelines must succeed settings](img/merge_when_pipeline_succeeds_only_if_succeeds_settings.png)
+### Limitations
+
+When this setting is enabled, a merge request is prevented from being merged if there
+is no pipeline. This may conflict with some use cases where [`only/except`](../../../ci/yaml/README.md#onlyexcept-advanced)
+or [`rules`](../../../ci/yaml/README.md#rules) are used and they don't generate any pipelines.
+
+You should ensure that [there is always a pipeline](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/54226)
+and that it's successful.
+
+If both a branch pipeline and a merge request pipeline are triggered for a single
+merge request, only the success or failure of the *merge request pipeline* is checked.
+If the merge request pipeline is configured with fewer jobs than the branch pipeline,
+it could allow code that fails tests to be merged:
+
+```yaml
+branch-pipeline-job:
+ rules:
+ - if: '$CI_PIPELINE_SOURCE == "push"'
+ script:
+ - echo "Code testing scripts here, for example."
-From now on, every time the pipeline fails you will not be able to merge the
-merge request from the UI, until you make all relevant jobs pass.
+merge-request-pipeline-job:
+ rules:
+ - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
+ script:
+ - echo "No tests run, but this pipeline always succeeds and enables merge."
+ - echo true
+```
-![Only allow merge if pipeline succeeds message](img/merge_when_pipeline_succeeds_only_if_succeeds_msg.png)
+You should avoid configuration like this, and only use branch (`push`) pipelines
+or merge request pipelines, when possible. See [`rules` documentation](../../../ci/yaml/README.md#differences-between-rules-and-onlyexcept)
+for details on avoiding two pipelines for a single merge request.
### Skipped pipelines
@@ -72,20 +100,10 @@ merge requests from being merged. To change this behavior:
1. In the **Merge checks** subsection, select the **Skipped pipelines are considered successful** checkbox.
1. Press **Save** for the changes to take effect.
-### Limitations
-
-When this setting is enabled, a merge request is prevented from being merged if there is no pipeline. This may conflict with some use cases where [`only/except`](../../../ci/yaml/README.md#onlyexcept-advanced) rules are used and they don't generate any pipelines.
-
-Users that expect to be able to merge a merge request in this scenario should ensure that [there is always a pipeline](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/54226) and that it's successful.
+## From the command line
-For example, to that on merge requests there is always a passing job even though `only/except` rules may not generate any other jobs:
-
-```yaml
-enable_merge:
- only: [merge_requests]
- script:
- - echo true
-```
+You can use [Push Options](../push_options.md) to enable merge when pipeline succeeds
+for a merge request when pushing from the command line.
<!-- ## Troubleshooting
@@ -98,8 +116,3 @@ questions that you know someone might ask.
Each scenario can be a third-level heading, e.g. `### Getting error message X`.
If you have none to add when creating a doc, leave this section in place
but commented out to help encourage others to add to it in the future. -->
-
-## Use it from the command line
-
-You can use [Push Options](../push_options.md) to trigger this feature when
-pushing.
diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb
index 05391a3a0d2..2e6ac40a593 100644
--- a/lib/api/merge_requests.rb
+++ b/lib/api/merge_requests.rb
@@ -62,10 +62,8 @@ module API
# rubocop: enable CodeReuse/ActiveRecord
def merge_request_pipelines_with_access
- authorize! :read_pipeline, user_project
-
mr = find_merge_request_with_access(params[:merge_request_iid])
- mr.all_pipelines
+ ::Ci::PipelinesForMergeRequestFinder.new(mr, current_user).execute
end
def automatically_mergeable?(merge_when_pipeline_succeeds, merge_request)
@@ -384,8 +382,6 @@ module API
success Entities::Pipeline
end
post ':id/merge_requests/:merge_request_iid/pipelines' do
- authorize! :create_pipeline, user_project
-
pipeline = ::MergeRequests::CreatePipelineService
.new(user_project, current_user, allow_duplicate: true)
.execute(find_merge_request_with_access(params[:merge_request_iid]))
diff --git a/lib/gitlab/ci/features.rb b/lib/gitlab/ci/features.rb
index 5593bdaa723..160e092c02e 100644
--- a/lib/gitlab/ci/features.rb
+++ b/lib/gitlab/ci/features.rb
@@ -69,6 +69,10 @@ module Gitlab
def self.bulk_insert_on_create?(project)
::Feature.enabled?(:ci_bulk_insert_on_create, project, default_enabled: true)
end
+
+ def self.allow_to_create_merge_request_pipelines_in_target_project?(target_project)
+ ::Feature.enabled?(:ci_allow_to_create_merge_request_pipelines_in_target_project, target_project)
+ end
end
end
end
diff --git a/lib/gitlab/metrics/dashboard/stages/sorter.rb b/lib/gitlab/metrics/dashboard/stages/sorter.rb
index ba5aa78059c..882211e1441 100644
--- a/lib/gitlab/metrics/dashboard/stages/sorter.rb
+++ b/lib/gitlab/metrics/dashboard/stages/sorter.rb
@@ -16,7 +16,7 @@ module Gitlab
# Sorts the groups in the dashboard by the :priority key
def sort_groups!
- dashboard[:panel_groups] = dashboard[:panel_groups].sort_by { |group| -group[:priority].to_i }
+ dashboard[:panel_groups] = Gitlab::Utils.stable_sort_by(dashboard[:panel_groups]) { |group| -group[:priority].to_i }
end
# Sorts the panels in the dashboard by the :weight key
@@ -24,7 +24,7 @@ module Gitlab
dashboard[:panel_groups].each do |group|
missing_panels! unless group[:panels].is_a? Array
- group[:panels] = group[:panels].sort_by { |panel| -panel[:weight].to_i }
+ group[:panels] = Gitlab::Utils.stable_sort_by(group[:panels]) { |panel| -panel[:weight].to_i }
end
end
end
diff --git a/lib/gitlab/utils.rb b/lib/gitlab/utils.rb
index 5dfe8fc7ae3..8f5c1eda456 100644
--- a/lib/gitlab/utils.rb
+++ b/lib/gitlab/utils.rb
@@ -178,5 +178,15 @@ module Gitlab
.group_by(&:first)
.transform_values { |kvs| kvs.map(&:last) }
end
+
+ # This sort is stable (see https://en.wikipedia.org/wiki/Sorting_algorithm#Stability)
+ # contrary to the bare Ruby sort_by method. Using just sort_by leads to
+ # instability across different platforms (e.g., x86_64-linux and x86_64-darwin18)
+ # which in turn leads to different sorting results for the equal elements across
+ # these platforms.
+ # This method uses a list item's original index position to break ties.
+ def stable_sort_by(list)
+ list.sort_by.with_index { |x, idx| [yield(x), idx] }
+ end
end
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index a89ea118938..d0d112fb433 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -757,9 +757,6 @@ msgid_plural "(%d closed)"
msgstr[0] ""
msgstr[1] ""
-msgid "(%{linkStart}Cron syntax%{linkEnd})"
-msgstr ""
-
msgid "(%{mrCount} merged)"
msgstr ""
@@ -4598,6 +4595,9 @@ msgstr ""
msgid "Choose file…"
msgstr ""
+msgid "Choose labels"
+msgstr ""
+
msgid "Choose the top-level group for your repository imports."
msgstr ""
@@ -8529,6 +8529,9 @@ msgstr ""
msgid "Edit files in the editor and commit changes here"
msgstr ""
+msgid "Edit fork in Web IDE"
+msgstr ""
+
msgid "Edit group: %{group_name}"
msgstr ""
@@ -9222,9 +9225,15 @@ msgstr ""
msgid "Epics|Are you sure you want to remove %{bStart}%{targetIssueTitle}%{bEnd} from %{bStart}%{parentEpicTitle}%{bEnd}?"
msgstr ""
+msgid "Epics|Enter a title for your epic"
+msgstr ""
+
msgid "Epics|How can I solve this?"
msgstr ""
+msgid "Epics|Leave empty to inherit from milestone dates"
+msgstr ""
+
msgid "Epics|More information"
msgstr ""
@@ -9264,12 +9273,18 @@ msgstr ""
msgid "Epics|These dates affect how your epics appear in the roadmap. Dates from milestones come from the milestones assigned to issues in the epic. You can also set fixed dates or remove them entirely."
msgstr ""
+msgid "Epics|This epic and any containing child epics are confidential and should only be visible to team members with at least Reporter access."
+msgstr ""
+
msgid "Epics|This will also remove any descendents of %{bStart}%{targetEpicTitle}%{bEnd} from %{bStart}%{parentEpicTitle}%{bEnd}. Are you sure?"
msgstr ""
msgid "Epics|To schedule your epic's %{epicDateType} date based on milestones, assign a milestone with a %{epicDateType} date to any issue in the epic."
msgstr ""
+msgid "Epics|Unable to save epic. Please try again"
+msgstr ""
+
msgid "Epics|due"
msgstr ""
@@ -15416,6 +15431,9 @@ msgstr ""
msgid "New Environment"
msgstr ""
+msgid "New Epic"
+msgstr ""
+
msgid "New File"
msgstr ""
@@ -16881,6 +16899,9 @@ msgstr ""
msgid "PipelineCharts|Total:"
msgstr ""
+msgid "PipelineScheduleIntervalPattern|Custom (%{linkStart}Cron syntax%{linkEnd})"
+msgstr ""
+
msgid "PipelineSchedules|Activated"
msgstr ""
@@ -16911,9 +16932,6 @@ msgstr ""
msgid "PipelineSchedules|Variables"
msgstr ""
-msgid "PipelineSheduleIntervalPattern|Custom"
-msgstr ""
-
msgid "PipelineStatusTooltip|Pipeline: %{ciStatus}"
msgstr ""
@@ -20960,6 +20978,9 @@ msgstr ""
msgid "Select health status"
msgstr ""
+msgid "Select label"
+msgstr ""
+
msgid "Select labels"
msgstr ""
diff --git a/qa/qa/page/project/show.rb b/qa/qa/page/project/show.rb
index d9d310d9c8c..2354a0d9332 100644
--- a/qa/qa/page/project/show.rb
+++ b/qa/qa/page/project/show.rb
@@ -56,7 +56,7 @@ module QA
element :new_file_option
end
- view 'app/assets/javascripts/repository/index.js' do
+ view 'app/assets/javascripts/repository/components/web_ide_link.vue' do
element :web_ide_button
end
diff --git a/spec/controllers/projects/incident_management/pager_duty_incidents_controller_spec.rb b/spec/controllers/projects/incident_management/pager_duty_incidents_controller_spec.rb
deleted file mode 100644
index 92df9c65698..00000000000
--- a/spec/controllers/projects/incident_management/pager_duty_incidents_controller_spec.rb
+++ /dev/null
@@ -1,45 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Projects::IncidentManagement::PagerDutyIncidentsController do
- let_it_be(:project) { create(:project) }
-
- describe 'POST #create' do
- let(:payload) { { messages: [] } }
-
- def make_request
- post :create, params: project_params, body: payload.to_json, as: :json
- end
-
- context 'when pagerduty_webhook feature enabled' do
- before do
- stub_feature_flags(pagerduty_webhook: project)
- end
-
- it 'responds with 202 Accepted' do
- make_request
-
- expect(response).to have_gitlab_http_status(:accepted)
- end
- end
-
- context 'when pagerduty_webhook feature disabled' do
- before do
- stub_feature_flags(pagerduty_webhook: false)
- end
-
- it 'responds with 401 Unauthorized' do
- make_request
-
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
- end
- end
-
- private
-
- def project_params(opts = {})
- opts.reverse_merge(namespace_id: project.namespace, project_id: project)
- end
-end
diff --git a/spec/features/issues/move_spec.rb b/spec/features/issues/move_spec.rb
index f3a6655f397..ee2fbf0865e 100644
--- a/spec/features/issues/move_spec.rb
+++ b/spec/features/issues/move_spec.rb
@@ -97,6 +97,45 @@ RSpec.describe 'issue move to another project' do
end
end
+ context 'service desk issue moved to a project with service desk disabled', :js do
+ let(:project_title) { 'service desk disabled project' }
+ let(:warning_selector) { '.js-alert-moved-from-service-desk-warning' }
+ let(:namespace) { create(:namespace) }
+ let(:regular_project) { create(:project, title: project_title, service_desk_enabled: false) }
+ let(:service_desk_project) { build(:project, :private, namespace: namespace, service_desk_enabled: true) }
+ let(:service_desk_issue) { create(:issue, project: service_desk_project, author: ::User.support_bot) }
+
+ before do
+ allow(Gitlab).to receive(:com?).and_return(true)
+ allow(Gitlab::IncomingEmail).to receive(:enabled?).and_return(true)
+ allow(Gitlab::IncomingEmail).to receive(:supports_wildcard?).and_return(true)
+
+ regular_project.add_reporter(user)
+ service_desk_project.add_reporter(user)
+
+ visit issue_path(service_desk_issue)
+
+ find('.js-move-issue').click
+ wait_for_requests
+ find('.js-move-issue-dropdown-item', text: project_title).click
+ find('.js-move-issue-confirmation-button').click
+ end
+
+ it 'shows an alert after being moved' do
+ expect(page).to have_content('This project does not have Service Desk enabled')
+ end
+
+ it 'does not show an alert after being dismissed' do
+ find("#{warning_selector} .js-close").click
+
+ expect(page).to have_no_selector(warning_selector)
+
+ page.refresh
+
+ expect(page).to have_no_selector(warning_selector)
+ end
+ end
+
def issue_path(issue)
project_issue_path(issue.project, issue)
end
diff --git a/spec/features/issues/service_desk_spec.rb b/spec/features/issues/service_desk_spec.rb
new file mode 100644
index 00000000000..0995aa11654
--- /dev/null
+++ b/spec/features/issues/service_desk_spec.rb
@@ -0,0 +1,163 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Service Desk Issue Tracker', :js do
+ let(:project) { create(:project, :private, service_desk_enabled: true) }
+ let(:user) { create(:user) }
+
+ before do
+ stub_feature_flags(vue_issuables_list: false)
+
+ allow(Gitlab::IncomingEmail).to receive(:enabled?).and_return(true)
+ allow(Gitlab::IncomingEmail).to receive(:supports_wildcard?).and_return(true)
+
+ project.add_maintainer(user)
+ sign_in(user)
+ end
+
+ describe 'navigation to service desk' do
+ before do
+ visit project_path(project)
+ find('.sidebar-top-level-items .shortcuts-issues').click
+ find('.sidebar-sub-level-items a[title="Service Desk"]').click
+ end
+
+ it 'can navigate to the service desk from link in the sidebar' do
+ expect(page).to have_content('Use Service Desk to connect with your users')
+ end
+ end
+
+ describe 'issues list' do
+ context 'when service desk is misconfigured' do
+ before do
+ allow(Gitlab::ServiceDesk).to receive(:supported?).and_return(false)
+ visit service_desk_project_issues_path(project)
+ end
+
+ it 'shows a message to say the configuration is incomplete' do
+ expect(page).to have_css('.empty-state')
+ expect(page).to have_text('Service Desk is enabled but not yet active')
+ expect(page).to have_text('You must set up incoming email before it becomes active')
+ expect(page).to have_link('More information', href: help_page_path('administration/incoming_email', anchor: 'set-it-up'))
+ end
+ end
+
+ context 'when service desk has not been activated' do
+ let(:project_without_service_desk) { create(:project, :private, service_desk_enabled: false) }
+
+ describe 'service desk info content' do
+ context 'when user has permissions to edit project settings' do
+ before do
+ project_without_service_desk.add_maintainer(user)
+ visit service_desk_project_issues_path(project_without_service_desk)
+ end
+
+ it 'displays the large info box, documentation, and a button to configure' do
+ aggregate_failures do
+ expect(page).to have_css('.empty-state')
+ expect(page).to have_link('Read more', href: help_page_path('user/project/service_desk'))
+ expect(page).to have_link('Turn on Service Desk')
+ end
+ end
+ end
+
+ context 'when user does not have permission to edit project settings' do
+ before do
+ project_without_service_desk.add_guest(user)
+ visit service_desk_project_issues_path(project_without_service_desk)
+ end
+
+ it 'does not show a button configure service desk' do
+ expect(page).not_to have_link('Turn on Service Desk')
+ end
+ end
+ end
+ end
+
+ context 'when service desk has been activated' do
+ context 'when there are no issues' do
+ describe 'service desk info content' do
+ before do
+ visit service_desk_project_issues_path(project)
+ end
+
+ it 'displays the large info box, documentation, and the address' do
+ aggregate_failures do
+ expect(page).to have_css('.empty-state')
+ expect(page).to have_link('Read more', href: help_page_path('user/project/service_desk'))
+ expect(page).not_to have_link('Turn on Service Desk')
+ expect(page).to have_content(project.service_desk_address)
+ end
+ end
+
+ context 'when user does not have permission to edit project settings' do
+ before do
+ user_2 = create(:user)
+
+ project.add_guest(user_2)
+ sign_in(user_2)
+ visit service_desk_project_issues_path(project)
+ end
+
+ it 'displays the large info box and the documentation link' do
+ aggregate_failures do
+ expect(page).to have_css('.empty-state')
+ expect(page).to have_link('Read more', href: help_page_path('user/project/service_desk'))
+ expect(page).not_to have_link('Turn on Service Desk')
+ expect(page).not_to have_content(project.service_desk_address)
+ end
+ end
+ end
+ end
+ end
+
+ context 'when there are issues' do
+ let(:support_bot) { User.support_bot }
+ let(:other_user) { create(:user) }
+ let!(:service_desk_issue) { create(:issue, project: project, author: support_bot) }
+ let!(:other_user_issue) { create(:issue, project: project, author: other_user) }
+
+ describe 'service desk info content' do
+ before do
+ visit service_desk_project_issues_path(project)
+ end
+
+ it 'displays the small info box, documentation, a button to configure service desk, and the address' do
+ aggregate_failures do
+ expect(page).to have_css('.non-empty-state')
+ expect(page).to have_link('Read more', href: help_page_path('user/project/service_desk'))
+ expect(page).not_to have_link('Turn on Service Desk')
+ expect(page).to have_content(project.service_desk_address)
+ end
+ end
+ end
+
+ describe 'issues list' do
+ before do
+ visit service_desk_project_issues_path(project)
+ end
+
+ it 'only displays issues created by support bot' do
+ expect(page).to have_selector('.issues-list .issue', count: 1)
+ end
+ end
+
+ describe 'search box' do
+ before do
+ visit service_desk_project_issues_path(project)
+ end
+
+ it 'adds hidden support bot author token' do
+ expect(page).to have_selector('.filtered-search-token .value', text: 'Support Bot', visible: false)
+ end
+
+ it 'support bot author token cannot be deleted' do
+ find('.input-token .filtered-search').native.send_key(:backspace)
+ expect(page).to have_selector('.js-visual-token', count: 1)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_sees_pipelines_spec.rb b/spec/features/merge_request/user_sees_pipelines_spec.rb
index b6f375b8815..d693eec91c8 100644
--- a/spec/features/merge_request/user_sees_pipelines_spec.rb
+++ b/spec/features/merge_request/user_sees_pipelines_spec.rb
@@ -84,6 +84,111 @@ RSpec.describe 'Merge request > User sees pipelines', :js do
end
end
+ describe 'fork MRs in parent project', :sidekiq_inline do
+ include ProjectForksHelper
+
+ let_it_be(:parent_project) { create(:project, :public, :repository) }
+ let_it_be(:forked_project) { fork_project(parent_project, developer_in_fork, repository: true, target_project: create(:project, :public, :repository)) }
+ let_it_be(:developer_in_parent) { create(:user) }
+ let_it_be(:developer_in_fork) { create(:user) }
+ let_it_be(:reporter_in_parent_and_developer_in_fork) { create(:user) }
+
+ let(:merge_request) do
+ create(:merge_request, :with_detached_merge_request_pipeline,
+ source_project: forked_project, source_branch: 'feature',
+ target_project: parent_project, target_branch: 'master')
+ end
+
+ let(:config) do
+ { test: { script: 'test', rules: [{ if: '$CI_MERGE_REQUEST_ID' }] } }
+ end
+
+ before_all do
+ parent_project.add_developer(developer_in_parent)
+ parent_project.add_reporter(reporter_in_parent_and_developer_in_fork)
+ forked_project.add_developer(developer_in_fork)
+ forked_project.add_developer(reporter_in_parent_and_developer_in_fork)
+ end
+
+ before do
+ stub_ci_pipeline_yaml_file(YAML.dump(config))
+ sign_in(actor)
+ end
+
+ after do
+ parent_project.all_pipelines.delete_all
+ forked_project.all_pipelines.delete_all
+ end
+
+ context 'when actor is a developer in parent project' do
+ let(:actor) { developer_in_parent }
+
+ it 'creates a pipeline in the parent project' do
+ visit project_merge_request_path(parent_project, merge_request)
+
+ create_merge_request_pipeline
+
+ check_pipeline(expected_project: parent_project)
+ check_head_pipeline(expected_project: parent_project)
+ end
+ end
+
+ context 'when actor is a developer in fork project' do
+ let(:actor) { developer_in_fork }
+
+ it 'creates a pipeline in the fork project' do
+ visit project_merge_request_path(parent_project, merge_request)
+
+ create_merge_request_pipeline
+
+ check_pipeline(expected_project: forked_project)
+ check_head_pipeline(expected_project: forked_project)
+ end
+ end
+
+ context 'when actor is a reporter in parent project and a developer in fork project' do
+ let(:actor) { reporter_in_parent_and_developer_in_fork }
+
+ it 'creates a pipeline in the fork project' do
+ visit project_merge_request_path(parent_project, merge_request)
+
+ create_merge_request_pipeline
+
+ check_pipeline(expected_project: forked_project)
+ check_head_pipeline(expected_project: forked_project)
+ end
+ end
+
+ def create_merge_request_pipeline
+ page.within('.merge-request-tabs') { click_link('Pipelines') }
+ click_button('Run Pipeline')
+ end
+
+ def check_pipeline(expected_project:)
+ page.within('.ci-table') do
+ expect(page).to have_selector('.commit', count: 2)
+
+ page.within(first('.commit')) do
+ page.within('.pipeline-tags') do
+ expect(page.find('.js-pipeline-url-link')[:href]).to include(expected_project.full_path)
+ expect(page).to have_content('detached')
+ end
+ page.within('.pipeline-triggerer') do
+ expect(page).to have_link(href: user_path(actor))
+ end
+ end
+ end
+ end
+
+ def check_head_pipeline(expected_project:)
+ page.within('.merge-request-tabs') { click_link('Overview') }
+
+ page.within('.ci-widget-content') do
+ expect(page.find('.pipeline-id')[:href]).to include(expected_project.full_path)
+ end
+ end
+ end
+
describe 'race condition' do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
diff --git a/spec/features/projects/environments/environment_metrics_spec.rb b/spec/features/projects/environments/environment_metrics_spec.rb
index a3b979d0f42..c72f88205b5 100644
--- a/spec/features/projects/environments/environment_metrics_spec.rb
+++ b/spec/features/projects/environments/environment_metrics_spec.rb
@@ -28,8 +28,9 @@ RSpec.describe 'Environment > Metrics' do
shared_examples 'has environment selector' do
it 'has a working environment selector', :js do
click_link('See metrics')
-
- expect(page).to have_metrics_path(environment)
+ # TODO: See metrics on the sidebar still points to the old metrics URL
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/229277
+ expect(page).to have_current_path(metrics_project_environment_path(project, id: environment.id))
expect(page).to have_css('[data-qa-selector="environments_dropdown"]')
within('[data-qa-selector="environments_dropdown"]') do
@@ -40,7 +41,7 @@ RSpec.describe 'Environment > Metrics' do
click_on(staging.name)
end
- expect(page).to have_metrics_path(staging)
+ expect(page).to have_current_path(project_metrics_dashboard_path(project, environment: staging.id))
wait_for_requests
end
@@ -67,8 +68,4 @@ RSpec.describe 'Environment > Metrics' do
def visit_environment(environment)
visit project_environment_path(environment.project, environment)
end
-
- def have_metrics_path(environment)
- have_current_path(metrics_project_environment_path(project, id: environment.id))
- end
end
diff --git a/spec/features/projects/navbar_spec.rb b/spec/features/projects/navbar_spec.rb
index 0193712aeea..22cd832ff06 100644
--- a/spec/features/projects/navbar_spec.rb
+++ b/spec/features/projects/navbar_spec.rb
@@ -12,17 +12,6 @@ RSpec.describe 'Project navbar' do
let_it_be(:project) { create(:project, :repository) }
before do
- # TODO - This can be moved into 'project navbar structure' shared
- # context when service desk feature gets moved to core.
- # More information in: https://gitlab.com/gitlab-org/gitlab/-/issues/215364
- if Gitlab.ee?
- insert_after_sub_nav_item(
- _('Labels'),
- within: _('Issues'),
- new_sub_nav_item_name: _('Service Desk')
- )
- end
-
project.add_maintainer(user)
sign_in(user)
end
diff --git a/spec/features/projects/settings/service_desk_setting_spec.rb b/spec/features/projects/settings/service_desk_setting_spec.rb
new file mode 100644
index 00000000000..7856ab1fb4e
--- /dev/null
+++ b/spec/features/projects/settings/service_desk_setting_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Service Desk Setting', :js do
+ let(:project) { create(:project_empty_repo, :private, service_desk_enabled: false) }
+ let(:presenter) { project.present(current_user: user) }
+ let(:user) { create(:user) }
+
+ before do
+ project.add_maintainer(user)
+ sign_in(user)
+
+ allow_any_instance_of(Project).to receive(:present).with(current_user: user).and_return(presenter)
+ allow(::Gitlab::IncomingEmail).to receive(:enabled?) { true }
+ allow(::Gitlab::IncomingEmail).to receive(:supports_wildcard?) { true }
+
+ visit edit_project_path(project)
+ end
+
+ it 'shows activation checkbox' do
+ expect(page).to have_selector("#service-desk-checkbox")
+ end
+
+ it 'shows incoming email after activating' do
+ find("#service-desk-checkbox").click
+ wait_for_requests
+ project.reload
+ expect(project.service_desk_enabled).to be_truthy
+ expect(project.service_desk_address).to be_present
+ expect(find('.incoming-email').value).to eq(project.service_desk_address)
+ end
+end
diff --git a/spec/features/promotion_spec.rb b/spec/features/promotion_spec.rb
new file mode 100644
index 00000000000..9344f9b56b8
--- /dev/null
+++ b/spec/features/promotion_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Promotions', :js do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project_empty_repo) }
+
+ describe 'for service desk', :js do
+ before do
+ project.add_maintainer(user)
+ sign_in(user)
+ end
+
+ context 'when service desk is not supported' do
+ before do
+ allow(::Gitlab::ServiceDesk).to receive(:supported?).and_return(false)
+ end
+
+ it 'appears in project edit page' do
+ visit edit_project_path(project)
+
+ expect(find('#promote_service_desk')).to have_content 'Improve customer support with GitLab Service Desk.'
+ end
+
+ it 'does not show when cookie is set' do
+ visit edit_project_path(project)
+
+ within('#promote_service_desk') do
+ find('.close').click
+ end
+
+ wait_for_requests
+
+ visit edit_project_path(project)
+
+ expect(page).not_to have_selector('#promote_service_desk')
+ end
+ end
+
+ context 'when service desk is supported' do
+ before do
+ allow(::Gitlab::ServiceDesk).to receive(:supported?).and_return(true)
+ end
+
+ it 'does not show promotion' do
+ visit edit_project_path(project)
+
+ expect(page).not_to have_selector('#promote_service_desk')
+ end
+ end
+ end
+end
diff --git a/spec/finders/ci/pipelines_for_merge_request_finder_spec.rb b/spec/finders/ci/pipelines_for_merge_request_finder_spec.rb
index 543c289d366..ca6e0793d55 100644
--- a/spec/finders/ci/pipelines_for_merge_request_finder_spec.rb
+++ b/spec/finders/ci/pipelines_for_merge_request_finder_spec.rb
@@ -3,11 +3,97 @@
require 'spec_helper'
RSpec.describe Ci::PipelinesForMergeRequestFinder do
+ describe '#execute' do
+ include ProjectForksHelper
+
+ subject { finder.execute }
+
+ let_it_be(:developer_in_parent) { create(:user) }
+ let_it_be(:developer_in_fork) { create(:user) }
+ let_it_be(:developer_in_both) { create(:user) }
+ let_it_be(:reporter_in_parent_and_developer_in_fork) { create(:user) }
+ let_it_be(:external_user) { create(:user) }
+ let_it_be(:parent_project) { create(:project, :repository, :private) }
+ let_it_be(:forked_project) { fork_project(parent_project, nil, repository: true, target_project: create(:project, :private, :repository)) }
+
+ let(:merge_request) do
+ create(:merge_request, source_project: forked_project, source_branch: 'feature',
+ target_project: parent_project, target_branch: 'master')
+ end
+
+ let!(:pipeline_in_parent) do
+ create(:ci_pipeline, :merged_result_pipeline, merge_request: merge_request, project: parent_project)
+ end
+
+ let!(:pipeline_in_fork) do
+ create(:ci_pipeline, :merged_result_pipeline, merge_request: merge_request, project: forked_project)
+ end
+
+ let(:finder) { described_class.new(merge_request, actor) }
+
+ before_all do
+ parent_project.add_developer(developer_in_parent)
+ parent_project.add_developer(developer_in_both)
+ parent_project.add_reporter(reporter_in_parent_and_developer_in_fork)
+ forked_project.add_developer(developer_in_fork)
+ forked_project.add_developer(developer_in_both)
+ forked_project.add_developer(reporter_in_parent_and_developer_in_fork)
+ end
+
+ context 'when actor has permission to read pipelines in both parent and forked projects' do
+ let(:actor) { developer_in_both }
+
+ it 'returns all pipelines' do
+ is_expected.to eq([pipeline_in_fork, pipeline_in_parent])
+ end
+ end
+
+ context 'when actor has permission to read pipelines in both parent and forked projects' do
+ let(:actor) { reporter_in_parent_and_developer_in_fork }
+
+ it 'returns all pipelines' do
+ is_expected.to eq([pipeline_in_fork, pipeline_in_parent])
+ end
+ end
+
+ context 'when actor has permission to read pipelines in the parent project only' do
+ let(:actor) { developer_in_parent }
+
+ it 'returns pipelines in parent' do
+ is_expected.to eq([pipeline_in_parent])
+ end
+ end
+
+ context 'when actor has permission to read pipelines in the forked project only' do
+ let(:actor) { developer_in_fork }
+
+ it 'returns pipelines in fork' do
+ is_expected.to eq([pipeline_in_fork])
+ end
+ end
+
+ context 'when actor does not have permission to read pipelines' do
+ let(:actor) { external_user }
+
+ it 'returns nothing' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when actor is nil' do
+ let(:actor) { nil }
+
+ it 'returns nothing' do
+ is_expected.to be_empty
+ end
+ end
+ end
+
describe '#all' do
let(:merge_request) { create(:merge_request) }
let(:project) { merge_request.source_project }
- subject { described_class.new(merge_request) }
+ subject { described_class.new(merge_request, nil) }
shared_examples 'returning pipelines with proper ordering' do
let!(:all_pipelines) do
@@ -134,7 +220,7 @@ RSpec.describe Ci::PipelinesForMergeRequestFinder do
branch_pipeline_2,
branch_pipeline])
- expect(described_class.new(merge_request_2).all)
+ expect(described_class.new(merge_request_2, nil).all)
.to eq([detached_merge_request_pipeline_2,
branch_pipeline_2,
branch_pipeline])
diff --git a/spec/frontend/ci_variable_list/store/mutations_spec.js b/spec/frontend/ci_variable_list/store/mutations_spec.js
index b97d1e7174e..663b3486a17 100644
--- a/spec/frontend/ci_variable_list/store/mutations_spec.js
+++ b/spec/frontend/ci_variable_list/store/mutations_spec.js
@@ -41,7 +41,7 @@ describe('CI variable list mutations', () => {
variable_type: 'Variable',
key: '',
secret_value: '',
- protected: false,
+ protected_variable: false,
masked: false,
environment_scope: 'All (default)',
};
@@ -85,7 +85,7 @@ describe('CI variable list mutations', () => {
it('should set protected value to true', () => {
mutations[types.SET_VARIABLE_PROTECTED](stateCopy);
- expect(stateCopy.variable.protected).toBe(true);
+ expect(stateCopy.variable.protected_variable).toBe(true);
});
});
diff --git a/spec/frontend/ide/commit_icon_spec.js b/spec/frontend/ide/commit_icon_spec.js
index 90b8e34497c..e4a7394b089 100644
--- a/spec/frontend/ide/commit_icon_spec.js
+++ b/spec/frontend/ide/commit_icon_spec.js
@@ -11,7 +11,6 @@ const createFile = (name = 'name', id = name, type = '', parent = null) =>
name,
path: parent ? `${parent.path}/${name}` : name,
parentPath: parent ? parent.path : '',
- lastCommit: {},
});
describe('getCommitIconMap', () => {
diff --git a/spec/frontend/ide/helpers.js b/spec/frontend/ide/helpers.js
index de839fa99ca..a9620d26313 100644
--- a/spec/frontend/ide/helpers.js
+++ b/spec/frontend/ide/helpers.js
@@ -30,7 +30,6 @@ export const file = (name = 'name', id = name, type = '', parent = null) =>
name,
path: parent ? `${parent.path}/${name}` : name,
parentPath: parent ? parent.path : '',
- lastCommit: {},
});
export const createEntriesFromPaths = paths =>
diff --git a/spec/frontend/monitoring/components/dashboard_header_spec.js b/spec/frontend/monitoring/components/dashboard_header_spec.js
index a2fa667d9a0..5a1a615c703 100644
--- a/spec/frontend/monitoring/components/dashboard_header_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_header_spec.js
@@ -9,7 +9,7 @@ import {
selfMonitoringDashboardGitResponse,
dashboardHeaderProps,
} from '../mock_data';
-import { redirectTo, mergeUrlParams } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility';
jest.mock('~/lib/utils/url_utility', () => ({
redirectTo: jest.fn(),
@@ -46,6 +46,9 @@ describe('Dashboard header', () => {
});
describe('when a dashboard has been duplicated in the duplicate dashboard modal', () => {
+ beforeEach(() => {
+ store.state.monitoringDashboard.projectPath = 'root/sandbox';
+ });
/**
* The duplicate dashboard modal gets called both by a menu item from the
* dashboards dropdown and by an item from the actions menu.
@@ -58,12 +61,10 @@ describe('Dashboard header', () => {
window.location = new URL('https://localhost');
const newDashboard = dashboardGitResponse[1];
- const params = {
- dashboard: encodeURIComponent(newDashboard.path),
- };
- const newDashboardUrl = mergeUrlParams(params, window.location.href);
createShallowWrapper();
+
+ const newDashboardUrl = 'root/sandbox/-/metrics/dashboard.yml';
findDuplicateDashboardModal().vm.$emit('dashboardDuplicated', newDashboard);
return wrapper.vm.$nextTick().then(() => {
diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js
index 614b7a0fd6a..4b7f7a9ddb3 100644
--- a/spec/frontend/monitoring/components/dashboard_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_spec.js
@@ -433,6 +433,10 @@ describe('Dashboard', () => {
const findDashboardDropdown = () => wrapper.find(DashboardHeader).find(DashboardsDropdown);
beforeEach(() => {
+ store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, {
+ projectPath: TEST_HOST,
+ });
+
delete window.location;
window.location = { ...windowLocation, assign: jest.fn() };
createMountedWrapper();
@@ -446,10 +450,11 @@ describe('Dashboard', () => {
it('encodes dashboard param', () => {
findDashboardDropdown().vm.$emit('selectDashboard', {
- path: 'dashboard&copy.yml',
+ path: '.gitlab/dashboards/dashboard&copy.yml',
+ display_name: 'dashboard&copy.yml',
});
expect(window.location.assign).toHaveBeenCalledWith(
- `${TEST_HOST}/?dashboard=dashboard%2526copy.yml`,
+ `${TEST_HOST}/-/metrics/dashboard%26copy.yml`,
);
});
});
@@ -486,6 +491,8 @@ describe('Dashboard', () => {
beforeEach(() => {
store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, {
currentEnvironmentName: 'production',
+ currentDashboard: dashboardGitResponse[0].path,
+ projectPath: TEST_HOST,
});
createMountedWrapper({ hasMetrics: true });
setupStoreWithData(store);
@@ -498,9 +505,12 @@ describe('Dashboard', () => {
findAllEnvironmentsDropdownItems().wrappers.forEach((itemWrapper, index) => {
const anchorEl = itemWrapper.find('a');
- if (anchorEl.exists() && environmentData[index].metrics_path) {
+ if (anchorEl.exists()) {
const href = anchorEl.attributes('href');
- expect(href).toBe(environmentData[index].metrics_path);
+ const currentDashboard = encodeURIComponent(dashboardGitResponse[0].path);
+ const environmentId = encodeURIComponent(environmentData[index].id);
+ const url = `${TEST_HOST}/-/metrics/${currentDashboard}?environment=${environmentId}`;
+ expect(href).toBe(url);
}
});
});
diff --git a/spec/frontend/monitoring/fixture_data.js b/spec/frontend/monitoring/fixture_data.js
index c2556bc71dc..97edf7bda74 100644
--- a/spec/frontend/monitoring/fixture_data.js
+++ b/spec/frontend/monitoring/fixture_data.js
@@ -10,6 +10,7 @@ import { metricsResult } from './mock_data';
export const metricsDashboardResponse = getJSONFixture(
'metrics_dashboard/environment_metrics_dashboard.json',
);
+
export const metricsDashboardPayload = metricsDashboardResponse.dashboard;
const datasetState = stateAndPropsFromDataset(
@@ -22,7 +23,15 @@ const datasetState = stateAndPropsFromDataset(
),
);
-export const dashboardProps = datasetState.dataProps;
+// new properties like addDashboardDocumentationPath prop and alertsEndpoint
+// was recently added to dashboard.vue component this needs to be
+// added to fixtures data
+// https://gitlab.com/gitlab-org/gitlab/-/issues/229256
+export const dashboardProps = {
+ ...datasetState.dataProps,
+ addDashboardDocumentationPath: 'https://path/to/docs',
+ alertsEndpoint: null,
+};
export const metricsDashboardViewModel = mapToDashboardViewModel(metricsDashboardPayload);
diff --git a/spec/frontend/monitoring/pages/dashboard_page_spec.js b/spec/frontend/monitoring/pages/dashboard_page_spec.js
index 974dda5d3de..675165e9e56 100644
--- a/spec/frontend/monitoring/pages/dashboard_page_spec.js
+++ b/spec/frontend/monitoring/pages/dashboard_page_spec.js
@@ -1,21 +1,42 @@
import { shallowMount } from '@vue/test-utils';
+import { createStore } from '~/monitoring/stores';
import DashboardPage from '~/monitoring/pages/dashboard_page.vue';
import Dashboard from '~/monitoring/components/dashboard.vue';
import { dashboardProps } from '../fixture_data';
describe('monitoring/pages/dashboard_page', () => {
let wrapper;
+ let store;
+ let $route;
+
+ const buildRouter = () => {
+ const dashboard = {};
+ $route = {
+ params: { dashboard },
+ query: { dashboard },
+ };
+ };
const buildWrapper = (props = {}) => {
wrapper = shallowMount(DashboardPage, {
+ store,
propsData: {
...props,
},
+ mocks: {
+ $route,
+ },
});
};
const findDashboardComponent = () => wrapper.find(Dashboard);
+ beforeEach(() => {
+ buildRouter();
+ store = createStore();
+ jest.spyOn(store, 'dispatch').mockResolvedValue();
+ });
+
afterEach(() => {
if (wrapper) {
wrapper.destroy();
diff --git a/spec/frontend/monitoring/router_spec.js b/spec/frontend/monitoring/router_spec.js
new file mode 100644
index 00000000000..5b8f4b3c83e
--- /dev/null
+++ b/spec/frontend/monitoring/router_spec.js
@@ -0,0 +1,81 @@
+import { mount, createLocalVue } from '@vue/test-utils';
+import VueRouter from 'vue-router';
+import DashboardPage from '~/monitoring/pages/dashboard_page.vue';
+import Dashboard from '~/monitoring/components/dashboard.vue';
+import { createStore } from '~/monitoring/stores';
+import createRouter from '~/monitoring/router';
+import { dashboardProps } from './fixture_data';
+import { dashboardHeaderProps } from './mock_data';
+
+describe('Monitoring router', () => {
+ let router;
+ let store;
+ const propsData = { dashboardProps: { ...dashboardProps, ...dashboardHeaderProps } };
+ const NEW_BASE_PATH = '/project/my-group/test-project/-/metrics';
+ const OLD_BASE_PATH = '/project/my-group/test-project/-/environments/71146/metrics';
+
+ const createWrapper = (basePath, routeArg) => {
+ const localVue = createLocalVue();
+ localVue.use(VueRouter);
+
+ router = createRouter(basePath);
+ if (routeArg !== undefined) {
+ router.push(routeArg);
+ }
+
+ return mount(DashboardPage, {
+ localVue,
+ store,
+ router,
+ propsData,
+ });
+ };
+
+ beforeEach(() => {
+ store = createStore();
+ jest.spyOn(store, 'dispatch').mockResolvedValue();
+ });
+
+ afterEach(() => {
+ window.location.hash = '';
+ });
+
+ describe('support old URL with full dashboard path', () => {
+ it.each`
+ route | currentDashboard
+ ${'/dashboard.yml'} | ${'dashboard.yml'}
+ ${'/folder1/dashboard.yml'} | ${'folder1/dashboard.yml'}
+ ${'/?dashboard=dashboard.yml'} | ${'dashboard.yml'}
+ `('sets component as $componentName for path "$route"', ({ route, currentDashboard }) => {
+ const wrapper = createWrapper(OLD_BASE_PATH, route);
+
+ expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setCurrentDashboard', {
+ currentDashboard,
+ });
+
+ expect(wrapper.find(Dashboard)).toExist();
+ });
+ });
+
+ describe('supports new URL with short dashboard path', () => {
+ it.each`
+ route | currentDashboard
+ ${'/'} | ${null}
+ ${'/dashboard.yml'} | ${'dashboard.yml'}
+ ${'/folder1/dashboard.yml'} | ${'folder1/dashboard.yml'}
+ ${'/folder1%2Fdashboard.yml'} | ${'folder1/dashboard.yml'}
+ ${'/dashboard.yml'} | ${'dashboard.yml'}
+ ${'/config/prometheus/common_metrics.yml'} | ${'config/prometheus/common_metrics.yml'}
+ ${'/config/prometheus/pod_metrics.yml'} | ${'config/prometheus/pod_metrics.yml'}
+ ${'/config%2Fprometheus%2Fpod_metrics.yml'} | ${'config/prometheus/pod_metrics.yml'}
+ `('sets component as $componentName for path "$route"', ({ route, currentDashboard }) => {
+ const wrapper = createWrapper(NEW_BASE_PATH, route);
+
+ expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setCurrentDashboard', {
+ currentDashboard,
+ });
+
+ expect(wrapper.find(Dashboard)).toExist();
+ });
+ });
+});
diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js
index 9cc1d6eeb5a..9a119377542 100644
--- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js
+++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js
@@ -1,4 +1,5 @@
-import { shallowMount } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
+import { trimText } from 'helpers/text_helper';
import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue';
describe('Interval Pattern Input Component', () => {
@@ -14,15 +15,22 @@ describe('Interval Pattern Input Component', () => {
everyWeek: `0 ${mockHour} * * ${mockWeekDayIndex}`,
everyMonth: `0 ${mockHour} ${mockDay} * *`,
};
-
- const findEveryDayRadio = () => wrapper.find('#every-day');
- const findEveryWeekRadio = () => wrapper.find('#every-week');
- const findEveryMonthRadio = () => wrapper.find('#every-month');
- const findCustomRadio = () => wrapper.find('#custom');
+ const customKey = 'custom';
+ const everyDayKey = 'everyDay';
+ const cronIntervalNotInPreset = `0 12 * * *`;
+
+ const findEveryDayRadio = () => wrapper.find(`[data-testid=${everyDayKey}]`);
+ const findEveryWeekRadio = () => wrapper.find('[data-testid="everyWeek"]');
+ const findEveryMonthRadio = () => wrapper.find('[data-testid="everyMonth"]');
+ const findCustomRadio = () => wrapper.find(`[data-testid="${customKey}"]`);
const findCustomInput = () => wrapper.find('#schedule_cron');
- const selectEveryDayRadio = () => findEveryDayRadio().setChecked();
- const selectEveryWeekRadio = () => findEveryWeekRadio().setChecked();
- const selectEveryMonthRadio = () => findEveryMonthRadio().setChecked();
+ const findAllLabels = () => wrapper.findAll('label');
+ const findSelectedRadio = () =>
+ wrapper.findAll('input[type="radio"]').wrappers.find(x => x.element.checked);
+ const findSelectedRadioKey = () => findSelectedRadio()?.attributes('data-testid');
+ const selectEveryDayRadio = () => findEveryDayRadio().trigger('click');
+ const selectEveryWeekRadio = () => findEveryWeekRadio().trigger('click');
+ const selectEveryMonthRadio = () => findEveryMonthRadio().trigger('click');
const selectCustomRadio = () => findCustomRadio().trigger('click');
const createWrapper = (props = {}, data = {}) => {
@@ -30,7 +38,7 @@ describe('Interval Pattern Input Component', () => {
throw new Error('A wrapper already exists');
}
- wrapper = shallowMount(IntervalPatternInput, {
+ wrapper = mount(IntervalPatternInput, {
propsData: { ...props },
data() {
return {
@@ -63,8 +71,8 @@ describe('Interval Pattern Input Component', () => {
createWrapper();
});
- it('to a non empty string when no initial value is not passed', () => {
- expect(findCustomInput()).not.toBe('');
+ it('defaults to every day value when no `initialCronInterval` is passed', () => {
+ expect(findCustomInput().element.value).toBe(cronIntervalPresets.everyDay);
});
});
@@ -85,20 +93,20 @@ describe('Interval Pattern Input Component', () => {
createWrapper();
});
- it('when a default option is selected', () => {
+ it('when a default option is selected', async () => {
selectEveryDayRadio();
- return wrapper.vm.$nextTick().then(() => {
- expect(findCustomInput().attributes('disabled')).toBeUndefined();
- });
+ await wrapper.vm.$nextTick();
+
+ expect(findCustomInput().attributes('disabled')).toBeUndefined();
});
- it('when the custom option is selected', () => {
+ it('when the custom option is selected', async () => {
selectCustomRadio();
- return wrapper.vm.$nextTick().then(() => {
- expect(findCustomInput().attributes('disabled')).toBeUndefined();
- });
+ await wrapper.vm.$nextTick();
+
+ expect(findCustomInput().attributes('disabled')).toBeUndefined();
});
});
@@ -115,40 +123,83 @@ describe('Interval Pattern Input Component', () => {
});
});
+ describe('Time strings', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('renders each label for radio options properly', () => {
+ const labels = findAllLabels().wrappers.map(el => trimText(el.text()));
+
+ expect(labels).toEqual([
+ 'Every day (at 4:00am)',
+ 'Every week (Monday at 4:00am)',
+ 'Every month (Day 1 at 4:00am)',
+ 'Custom ( Cron syntax )',
+ ]);
+ });
+ });
+
describe('User Actions with radio buttons', () => {
- it.each`
- desc | initialCronInterval | act | expectedValue
- ${'when everyday is selected, update value'} | ${'1 2 3 4 5'} | ${selectEveryDayRadio} | ${cronIntervalPresets.everyDay}
- ${'when everyweek is selected, update value'} | ${'1 2 3 4 5'} | ${selectEveryWeekRadio} | ${cronIntervalPresets.everyWeek}
- ${'when everymonth is selected, update value'} | ${'1 2 3 4 5'} | ${selectEveryMonthRadio} | ${cronIntervalPresets.everyMonth}
- ${'when custom is selected, add space to value'} | ${cronIntervalPresets.everyMonth} | ${selectCustomRadio} | ${`${cronIntervalPresets.everyMonth} `}
- `('$desc', ({ initialCronInterval, act, expectedValue }) => {
- createWrapper({ initialCronInterval });
+ describe('Default option', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('when everyday is selected, update value', async () => {
+ selectEveryWeekRadio();
+ await wrapper.vm.$nextTick();
+ expect(findCustomInput().element.value).toBe(cronIntervalPresets.everyWeek);
+
+ selectEveryDayRadio();
+ await wrapper.vm.$nextTick();
+ expect(findCustomInput().element.value).toBe(cronIntervalPresets.everyDay);
+ });
+ });
+
+ describe('Other options', () => {
+ it.each`
+ desc | initialCronInterval | act | expectedValue
+ ${'when everyweek is selected, update value'} | ${'1 2 3 4 5'} | ${selectEveryWeekRadio} | ${cronIntervalPresets.everyWeek}
+ ${'when everymonth is selected, update value'} | ${'1 2 3 4 5'} | ${selectEveryMonthRadio} | ${cronIntervalPresets.everyMonth}
+ ${'when custom is selected, value remains the same'} | ${cronIntervalPresets.everyMonth} | ${selectCustomRadio} | ${cronIntervalPresets.everyMonth}
+ `('$desc', async ({ initialCronInterval, act, expectedValue }) => {
+ createWrapper({ initialCronInterval });
+
+ act();
- act();
+ await wrapper.vm.$nextTick();
- return wrapper.vm.$nextTick().then(() => {
expect(findCustomInput().element.value).toBe(expectedValue);
});
});
});
+
describe('User actions with input field for Cron syntax', () => {
beforeEach(() => {
createWrapper();
});
- it('when editing the cron input it selects the custom radio button', () => {
+ it('when editing the cron input it selects the custom radio button', async () => {
const newValue = '0 * * * *';
+ expect(findSelectedRadioKey()).toBe(everyDayKey);
+
findCustomInput().setValue(newValue);
- expect(wrapper.vm.cronInterval).toBe(newValue);
+ await wrapper.vm.$nextTick;
+
+ expect(findSelectedRadioKey()).toBe(customKey);
});
+ });
- it('when value of input is one of the defaults, it selects the corresponding radio button', () => {
- findCustomInput().setValue(cronIntervalPresets.everyWeek);
+ describe('Edit form field', () => {
+ beforeEach(() => {
+ createWrapper({ initialCronInterval: cronIntervalNotInPreset });
+ });
- expect(wrapper.vm.cronInterval).toBe(cronIntervalPresets.everyWeek);
+ it('loads with the custom option being selected', () => {
+ expect(findSelectedRadioKey()).toBe(customKey);
});
});
});
diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js
new file mode 100644
index 00000000000..4c873bdfd60
--- /dev/null
+++ b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js
@@ -0,0 +1,226 @@
+import { shallowMount, mount } from '@vue/test-utils';
+import AxiosMockAdapter from 'axios-mock-adapter';
+import waitForPromises from 'helpers/wait_for_promises';
+import ServiceDeskRoot from '~/projects/settings_service_desk/components/service_desk_root.vue';
+import axios from '~/lib/utils/axios_utils';
+import httpStatusCodes from '~/lib/utils/http_status';
+
+describe('ServiceDeskRoot', () => {
+ const endpoint = '/gitlab-org/gitlab-test/service_desk';
+ const initialIncomingEmail = 'servicedeskaddress@example.com';
+ let axiosMock;
+ let wrapper;
+ let spy;
+
+ beforeEach(() => {
+ axiosMock = new AxiosMockAdapter(axios);
+ });
+
+ afterEach(() => {
+ axiosMock.restore();
+ wrapper.destroy();
+ if (spy) {
+ spy.mockRestore();
+ }
+ });
+
+ it('fetches incoming email when there is no incoming email provided', () => {
+ axiosMock.onGet(endpoint).replyOnce(httpStatusCodes.OK);
+
+ wrapper = shallowMount(ServiceDeskRoot, {
+ propsData: {
+ initialIsEnabled: true,
+ initialIncomingEmail: '',
+ endpoint,
+ },
+ });
+
+ return wrapper.vm
+ .$nextTick()
+ .then(waitForPromises)
+ .then(() => {
+ expect(axiosMock.history.get).toHaveLength(1);
+ });
+ });
+
+ it('does not fetch incoming email when there is an incoming email provided', () => {
+ axiosMock.onGet(endpoint).replyOnce(httpStatusCodes.OK);
+
+ wrapper = shallowMount(ServiceDeskRoot, {
+ propsData: {
+ initialIsEnabled: true,
+ initialIncomingEmail,
+ endpoint,
+ },
+ });
+
+ return wrapper.vm
+ .$nextTick()
+ .then(waitForPromises)
+ .then(() => {
+ expect(axiosMock.history.get).toHaveLength(0);
+ });
+ });
+
+ it('shows an error message when incoming email is not fetched correctly', () => {
+ axiosMock.onGet(endpoint).networkError();
+
+ wrapper = shallowMount(ServiceDeskRoot, {
+ propsData: {
+ initialIsEnabled: true,
+ initialIncomingEmail: '',
+ endpoint,
+ },
+ });
+
+ return wrapper.vm
+ .$nextTick()
+ .then(waitForPromises)
+ .then(() => {
+ expect(wrapper.html()).toContain(
+ 'An error occurred while fetching the Service Desk address.',
+ );
+ });
+ });
+
+ it('sends a request to toggle service desk off when the toggle is clicked from the on state', () => {
+ axiosMock.onPut(endpoint).replyOnce(httpStatusCodes.OK);
+
+ spy = jest.spyOn(axios, 'put');
+
+ wrapper = mount(ServiceDeskRoot, {
+ propsData: {
+ initialIsEnabled: true,
+ initialIncomingEmail,
+ endpoint,
+ },
+ });
+
+ wrapper.find('button.gl-toggle').trigger('click');
+
+ return wrapper.vm
+ .$nextTick()
+ .then(waitForPromises)
+ .then(() => {
+ expect(spy).toHaveBeenCalledWith(endpoint, { service_desk_enabled: false });
+ });
+ });
+
+ it('sends a request to toggle service desk on when the toggle is clicked from the off state', () => {
+ axiosMock.onPut(endpoint).replyOnce(httpStatusCodes.OK);
+
+ spy = jest.spyOn(axios, 'put');
+
+ wrapper = mount(ServiceDeskRoot, {
+ propsData: {
+ initialIsEnabled: false,
+ initialIncomingEmail: '',
+ endpoint,
+ },
+ });
+
+ wrapper.find('button.gl-toggle').trigger('click');
+
+ return wrapper.vm.$nextTick(() => {
+ expect(spy).toHaveBeenCalledWith(endpoint, { service_desk_enabled: true });
+ });
+ });
+
+ it('shows an error message when there is an issue toggling service desk on', () => {
+ axiosMock.onPut(endpoint).networkError();
+
+ wrapper = mount(ServiceDeskRoot, {
+ propsData: {
+ initialIsEnabled: false,
+ initialIncomingEmail: '',
+ endpoint,
+ },
+ });
+
+ wrapper.find('button.gl-toggle').trigger('click');
+
+ return wrapper.vm
+ .$nextTick()
+ .then(waitForPromises)
+ .then(() => {
+ expect(wrapper.html()).toContain('An error occurred while enabling Service Desk.');
+ });
+ });
+
+ it('sends a request to update template when the "Save template" button is clicked', () => {
+ axiosMock.onPut(endpoint).replyOnce(httpStatusCodes.OK);
+
+ spy = jest.spyOn(axios, 'put');
+
+ wrapper = mount(ServiceDeskRoot, {
+ propsData: {
+ initialIsEnabled: true,
+ endpoint,
+ initialIncomingEmail,
+ selectedTemplate: 'Bug',
+ outgoingName: 'GitLab Support Bot',
+ templates: ['Bug', 'Documentation'],
+ projectKey: 'key',
+ },
+ });
+
+ wrapper.find('button.btn-success').trigger('click');
+
+ return wrapper.vm.$nextTick(() => {
+ expect(spy).toHaveBeenCalledWith(endpoint, {
+ issue_template_key: 'Bug',
+ outgoing_name: 'GitLab Support Bot',
+ project_key: 'key',
+ service_desk_enabled: true,
+ });
+ });
+ });
+
+ it('saves the template when the "Save template" button is clicked', () => {
+ axiosMock.onPut(endpoint).replyOnce(httpStatusCodes.OK);
+
+ wrapper = mount(ServiceDeskRoot, {
+ propsData: {
+ initialIsEnabled: true,
+ endpoint,
+ initialIncomingEmail,
+ selectedTemplate: 'Bug',
+ templates: ['Bug', 'Documentation'],
+ },
+ });
+
+ wrapper.find('button.btn-success').trigger('click');
+
+ return wrapper.vm
+ .$nextTick()
+ .then(waitForPromises)
+ .then(() => {
+ expect(wrapper.html()).toContain('Template was successfully saved.');
+ });
+ });
+
+ it('shows an error message when there is an issue saving the template', () => {
+ axiosMock.onPut(endpoint).networkError();
+
+ wrapper = mount(ServiceDeskRoot, {
+ propsData: {
+ initialIsEnabled: true,
+ endpoint,
+ initialIncomingEmail,
+ selectedTemplate: 'Bug',
+ templates: ['Bug', 'Documentation'],
+ },
+ });
+
+ wrapper.find('button.btn-success').trigger('click');
+
+ return wrapper.vm
+ .$nextTick()
+ .then(waitForPromises)
+ .then(() => {
+ expect(wrapper.html()).toContain(
+ 'An error occurred while saving the template. Please check if the template exists.',
+ );
+ });
+ });
+});
diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js
new file mode 100644
index 00000000000..7fe310aa400
--- /dev/null
+++ b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js
@@ -0,0 +1,234 @@
+import { shallowMount, mount } from '@vue/test-utils';
+import { GlLoadingIcon } from '@gitlab/ui';
+import eventHub from '~/projects/settings_service_desk/event_hub';
+import ServiceDeskSetting from '~/projects/settings_service_desk/components/service_desk_setting.vue';
+
+describe('ServiceDeskSetting', () => {
+ let wrapper;
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ }
+ });
+
+ const findTemplateDropdown = () => wrapper.find('#service-desk-template-select');
+
+ describe('when isEnabled=true', () => {
+ describe('only isEnabled', () => {
+ describe('as project admin', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(ServiceDeskSetting, {
+ propsData: {
+ isEnabled: true,
+ },
+ });
+ });
+
+ it('should see activation checkbox', () => {
+ expect(wrapper.contains('#service-desk-checkbox')).toBe(true);
+ });
+
+ it('should see main panel with the email info', () => {
+ expect(wrapper.contains('#incoming-email-describer')).toBe(true);
+ });
+
+ it('should see loading spinner and not the incoming email', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ expect(wrapper.contains('.incoming-email')).toBe(false);
+ });
+ });
+ });
+
+ describe('service desk toggle', () => {
+ it('emits an event to turn on Service Desk when clicked', () => {
+ const eventSpy = jest.fn();
+ eventHub.$on('serviceDeskEnabledCheckboxToggled', eventSpy);
+
+ wrapper = mount(ServiceDeskSetting, {
+ propsData: {
+ isEnabled: false,
+ },
+ });
+
+ wrapper.find('#service-desk-checkbox').trigger('click');
+
+ expect(eventSpy).toHaveBeenCalledWith(true);
+
+ eventHub.$off('serviceDeskEnabledCheckboxToggled', eventSpy);
+ eventSpy.mockRestore();
+ });
+ });
+
+ describe('with incomingEmail', () => {
+ const incomingEmail = 'foo@bar.com';
+
+ beforeEach(() => {
+ wrapper = mount(ServiceDeskSetting, {
+ propsData: {
+ isEnabled: true,
+ incomingEmail,
+ },
+ });
+ });
+
+ it('should see email and not the loading spinner', () => {
+ expect(wrapper.find('.incoming-email').element.value).toEqual(incomingEmail);
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
+ });
+
+ it('renders a copy to clipboard button', () => {
+ expect(wrapper.contains('.qa-clipboard-button')).toBe(true);
+ expect(wrapper.find('.qa-clipboard-button').element.dataset.clipboardText).toBe(
+ incomingEmail,
+ );
+ });
+ });
+
+ describe('templates dropdown', () => {
+ it('renders a dropdown to choose a template', () => {
+ wrapper = shallowMount(ServiceDeskSetting, {
+ propsData: {
+ isEnabled: true,
+ },
+ });
+
+ expect(wrapper.contains('#service-desk-template-select')).toBe(true);
+ });
+
+ it('renders a dropdown with a default value of ""', () => {
+ wrapper = mount(ServiceDeskSetting, {
+ propsData: {
+ isEnabled: true,
+ },
+ });
+
+ expect(findTemplateDropdown().element.value).toEqual('');
+ });
+
+ it('renders a dropdown with a value of "Bug" when it is the initial value', () => {
+ const templates = ['Bug', 'Documentation', 'Security release'];
+
+ wrapper = mount(ServiceDeskSetting, {
+ propsData: {
+ isEnabled: true,
+ initialSelectedTemplate: 'Bug',
+ templates,
+ },
+ });
+
+ expect(findTemplateDropdown().element.value).toEqual('Bug');
+ });
+
+ it('renders a dropdown with no options when the project has no templates', () => {
+ wrapper = mount(ServiceDeskSetting, {
+ propsData: {
+ isEnabled: true,
+ templates: [],
+ },
+ });
+
+ // The dropdown by default has one empty option
+ expect(findTemplateDropdown().element.children).toHaveLength(1);
+ });
+
+ it('renders a dropdown with options when the project has templates', () => {
+ const templates = ['Bug', 'Documentation', 'Security release'];
+ wrapper = mount(ServiceDeskSetting, {
+ propsData: {
+ isEnabled: true,
+ templates,
+ },
+ });
+
+ // An empty-named template is prepended so the user can select no template
+ const expectedTemplates = [''].concat(templates);
+
+ const dropdown = findTemplateDropdown();
+ const dropdownList = Array.from(dropdown.element.children).map(option => option.innerText);
+
+ expect(dropdown.element.children).toHaveLength(expectedTemplates.length);
+ expect(dropdownList.includes('Bug')).toEqual(true);
+ expect(dropdownList.includes('Documentation')).toEqual(true);
+ expect(dropdownList.includes('Security release')).toEqual(true);
+ });
+ });
+ });
+
+ describe('save button', () => {
+ it('renders a save button to save a template', () => {
+ wrapper = mount(ServiceDeskSetting, {
+ propsData: {
+ isEnabled: true,
+ },
+ });
+
+ expect(wrapper.find('button.btn-success').text()).toContain('Save template');
+ });
+
+ it('emits a save event with the chosen template when the save button is clicked', () => {
+ const eventSpy = jest.fn();
+ eventHub.$on('serviceDeskTemplateSave', eventSpy);
+
+ wrapper = mount(ServiceDeskSetting, {
+ propsData: {
+ isEnabled: true,
+ initialSelectedTemplate: 'Bug',
+ initialOutgoingName: 'GitLab Support Bot',
+ initialProjectKey: 'key',
+ },
+ });
+
+ wrapper.find('button.btn-success').trigger('click');
+
+ expect(eventSpy).toHaveBeenCalledWith({
+ selectedTemplate: 'Bug',
+ outgoingName: 'GitLab Support Bot',
+ projectKey: 'key',
+ });
+
+ eventHub.$off('serviceDeskTemplateSave', eventSpy);
+ eventSpy.mockRestore();
+ });
+ });
+
+ describe('when isEnabled=false', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(ServiceDeskSetting, {
+ propsData: {
+ isEnabled: false,
+ },
+ });
+ });
+
+ it('does not render email panel', () => {
+ expect(wrapper.contains('#incoming-email-describer')).toBe(false);
+ });
+
+ it('does not render template dropdown', () => {
+ expect(wrapper.contains('#service-desk-template-select')).toBe(false);
+ });
+
+ it('does not render template save button', () => {
+ expect(wrapper.contains('button.btn-success')).toBe(false);
+ });
+
+ it('emits an event to turn on Service Desk when the toggle is clicked', () => {
+ const eventSpy = jest.fn();
+ eventHub.$on('serviceDeskEnabledCheckboxToggled', eventSpy);
+
+ wrapper = mount(ServiceDeskSetting, {
+ propsData: {
+ isEnabled: true,
+ },
+ });
+
+ wrapper.find('#service-desk-checkbox').trigger('click');
+
+ expect(eventSpy).toHaveBeenCalledWith(false);
+
+ eventHub.$off('serviceDeskEnabledCheckboxToggled', eventSpy);
+ eventSpy.mockRestore();
+ });
+ });
+});
diff --git a/spec/frontend/projects/settings_service_desk/services/service_desk_service_spec.js b/spec/frontend/projects/settings_service_desk/services/service_desk_service_spec.js
new file mode 100644
index 00000000000..f9e4d55245a
--- /dev/null
+++ b/spec/frontend/projects/settings_service_desk/services/service_desk_service_spec.js
@@ -0,0 +1,129 @@
+import AxiosMockAdapter from 'axios-mock-adapter';
+import ServiceDeskService from '~/projects/settings_service_desk/services/service_desk_service';
+import axios from '~/lib/utils/axios_utils';
+import httpStatusCodes from '~/lib/utils/http_status';
+
+describe('ServiceDeskService', () => {
+ const endpoint = `/gitlab-org/gitlab-test/service_desk`;
+ const dummyResponse = { message: 'Dummy response' };
+ const errorMessage = 'Network Error';
+ let axiosMock;
+ let service;
+
+ beforeEach(() => {
+ axiosMock = new AxiosMockAdapter(axios);
+ service = new ServiceDeskService(endpoint);
+ });
+
+ afterEach(() => {
+ axiosMock.restore();
+ });
+
+ describe('fetchIncomingEmail', () => {
+ it('makes a request to fetch incoming email', () => {
+ axiosMock.onGet(endpoint).replyOnce(httpStatusCodes.OK, dummyResponse);
+
+ return service.fetchIncomingEmail().then(response => {
+ expect(response.data).toEqual(dummyResponse);
+ });
+ });
+
+ it('fails on error response', () => {
+ axiosMock.onGet(endpoint).networkError();
+
+ return service.fetchIncomingEmail().catch(error => {
+ expect(error.message).toBe(errorMessage);
+ });
+ });
+ });
+
+ describe('toggleServiceDesk', () => {
+ it('makes a request to set service desk', () => {
+ axiosMock.onPut(endpoint).replyOnce(httpStatusCodes.OK, dummyResponse);
+
+ return service.toggleServiceDesk(true).then(response => {
+ expect(response.data).toEqual(dummyResponse);
+ });
+ });
+
+ it('fails on error response', () => {
+ axiosMock.onPut(endpoint).networkError();
+
+ return service.toggleServiceDesk(true).catch(error => {
+ expect(error.message).toBe(errorMessage);
+ });
+ });
+
+ it('makes a request with the expected body', () => {
+ axiosMock.onPut(endpoint).replyOnce(httpStatusCodes.OK, dummyResponse);
+
+ const spy = jest.spyOn(axios, 'put');
+
+ service.toggleServiceDesk(true);
+
+ expect(spy).toHaveBeenCalledWith(endpoint, {
+ service_desk_enabled: true,
+ });
+
+ spy.mockRestore();
+ });
+ });
+
+ describe('updateTemplate', () => {
+ it('makes a request to update template', () => {
+ axiosMock.onPut(endpoint).replyOnce(httpStatusCodes.OK, dummyResponse);
+
+ return service
+ .updateTemplate(
+ {
+ selectedTemplate: 'Bug',
+ outgoingName: 'GitLab Support Bot',
+ },
+ true,
+ )
+ .then(response => {
+ expect(response.data).toEqual(dummyResponse);
+ });
+ });
+
+ it('fails on error response', () => {
+ axiosMock.onPut(endpoint).networkError();
+
+ return service
+ .updateTemplate(
+ {
+ selectedTemplate: 'Bug',
+ outgoingName: 'GitLab Support Bot',
+ },
+ true,
+ )
+ .catch(error => {
+ expect(error.message).toBe(errorMessage);
+ });
+ });
+
+ it('makes a request with the expected body', () => {
+ axiosMock.onPut(endpoint).replyOnce(httpStatusCodes.OK, dummyResponse);
+
+ const spy = jest.spyOn(axios, 'put');
+
+ service.updateTemplate(
+ {
+ selectedTemplate: 'Bug',
+ outgoingName: 'GitLab Support Bot',
+ projectKey: 'key',
+ },
+ true,
+ );
+
+ expect(spy).toHaveBeenCalledWith(endpoint, {
+ issue_template_key: 'Bug',
+ outgoing_name: 'GitLab Support Bot',
+ project_key: 'key',
+ service_desk_enabled: true,
+ });
+
+ spy.mockRestore();
+ });
+ });
+});
diff --git a/spec/frontend/repository/components/web_ide_link_spec.js b/spec/frontend/repository/components/web_ide_link_spec.js
new file mode 100644
index 00000000000..59e1a4fd719
--- /dev/null
+++ b/spec/frontend/repository/components/web_ide_link_spec.js
@@ -0,0 +1,51 @@
+import WebIdeLink from '~/repository/components/web_ide_link.vue';
+import { mount } from '@vue/test-utils';
+
+describe('Web IDE link component', () => {
+ let wrapper;
+
+ function createComponent(props) {
+ wrapper = mount(WebIdeLink, {
+ propsData: { ...props },
+ mocks: {
+ $route: {
+ params: {},
+ },
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders link to the Web IDE for a project if only projectPath is given', () => {
+ createComponent({ projectPath: 'gitlab-org/gitlab', refSha: 'master' });
+
+ expect(wrapper.attributes('href')).toBe('/-/ide/project/gitlab-org/gitlab/edit/master/-/');
+ expect(wrapper.text()).toBe('Web IDE');
+ });
+
+ it('renders link to the Web IDE for a project even if both projectPath and forkPath are given', () => {
+ createComponent({
+ projectPath: 'gitlab-org/gitlab',
+ refSha: 'master',
+ forkPath: 'my-namespace/gitlab',
+ });
+
+ expect(wrapper.attributes('href')).toBe('/-/ide/project/gitlab-org/gitlab/edit/master/-/');
+ expect(wrapper.text()).toBe('Web IDE');
+ });
+
+ it('renders link to the forked project if it exists and cannot write to the repo', () => {
+ createComponent({
+ projectPath: 'gitlab-org/gitlab',
+ refSha: 'master',
+ forkPath: 'my-namespace/gitlab',
+ canPushCode: false,
+ });
+
+ expect(wrapper.attributes('href')).toBe('/-/ide/project/my-namespace/gitlab/edit/master/-/');
+ expect(wrapper.text()).toBe('Edit fork in Web IDE');
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js
index 0007aed5c4d..18dff0a39bb 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js
@@ -1,4 +1,5 @@
import {
+ buildTextToken,
buildUneditableOpenTokens,
buildUneditableCloseToken,
buildUneditableCloseTokens,
@@ -19,6 +20,13 @@ import {
} from './mock_data';
describe('Build Uneditable Token renderer helper', () => {
+ describe('buildTextToken', () => {
+ it('returns an object literal representing a text token', () => {
+ const text = originToken.content;
+ expect(buildTextToken(text)).toStrictEqual(originToken);
+ });
+ });
+
describe('buildUneditableOpenTokens', () => {
it('returns a 2-item array of tokens with the originToken appended to an open token', () => {
const result = buildUneditableOpenTokens(originToken);
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js
index 433f41774b4..b35d1bb463a 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js
@@ -29,6 +29,7 @@ const buildMockUneditableCloseToken = type => {
export const originToken = {
type: 'text',
+ tagName: null,
content: '{:.no_toc .hidden-md .hidden-lg}',
};
export const uneditableCloseToken = buildMockUneditableCloseToken('div');
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js
new file mode 100644
index 00000000000..2897929f1bf
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js
@@ -0,0 +1,55 @@
+import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text';
+import { buildUneditableInlineTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
+
+import { buildMockTextNode, normalTextNode } from './mock_data';
+
+const mockTextStart = 'Majority example ';
+const mockTextMiddle = '[environment terraform plans][terraform]';
+const mockTextEnd = '.';
+const identifierInstanceStartTextNode = buildMockTextNode(mockTextStart);
+const identifierInstanceEndTextNode = buildMockTextNode(mockTextEnd);
+
+describe('Render Identifier Instance Text renderer', () => {
+ describe('canRender', () => {
+ it.each`
+ node | target
+ ${normalTextNode} | ${false}
+ ${identifierInstanceStartTextNode} | ${false}
+ ${identifierInstanceEndTextNode} | ${false}
+ ${buildMockTextNode(mockTextMiddle)} | ${true}
+ ${buildMockTextNode('Minority example [environment terraform plans][]')} | ${true}
+ ${buildMockTextNode('Minority example [environment terraform plans]')} | ${true}
+ `(
+ 'should return $target when the $node validates against identifier instance syntax',
+ ({ node, target }) => {
+ expect(renderer.canRender(node)).toBe(target);
+ },
+ );
+ });
+
+ describe('render', () => {
+ it.each`
+ start | middle | end
+ ${mockTextStart} | ${mockTextMiddle} | ${mockTextEnd}
+ ${mockTextStart} | ${'[environment terraform plans][]'} | ${mockTextEnd}
+ ${mockTextStart} | ${'[environment terraform plans]'} | ${mockTextEnd}
+ `(
+ 'should return inline editable, uneditable, and editable tokens in sequence',
+ ({ start, middle, end }) => {
+ const buildMockTextToken = content => ({ type: 'text', tagName: null, content });
+
+ const startToken = buildMockTextToken(start);
+ const middleToken = buildMockTextToken(middle);
+ const endToken = buildMockTextToken(end);
+
+ const content = `${start}${middle}${end}`;
+ const contentToken = buildMockTextToken(content);
+ const contentNode = buildMockTextNode(content);
+ const context = { origin: jest.fn().mockReturnValueOnce(contentToken) };
+ expect(renderer.render(contentNode, context)).toStrictEqual(
+ [startToken, buildUneditableInlineTokens(middleToken), endToken].flat(),
+ );
+ },
+ );
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js
index 214eb239432..68c9d26bb1a 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js
@@ -1,18 +1,19 @@
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlButton } from '@gitlab/ui';
import DropdownButton from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue';
import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
import { mockConfig } from './mock_data';
+let store;
const localVue = createLocalVue();
localVue.use(Vuex);
const createComponent = (initialState = mockConfig) => {
- const store = new Vuex.Store(labelSelectModule());
+ store = new Vuex.Store(labelSelectModule());
store.dispatch('setInitialState', initialState);
@@ -33,26 +34,32 @@ describe('DropdownButton', () => {
wrapper.destroy();
});
+ const findDropdownButton = () => wrapper.find(GlButton);
+ const findDropdownText = () => wrapper.find('.dropdown-toggle-text');
+ const findDropdownIcon = () => wrapper.find(GlIcon);
+
describe('methods', () => {
describe('handleButtonClick', () => {
- it('calls action `toggleDropdownContents` and stops event propagation when `state.variant` is "standalone"', () => {
- const event = {
- stopPropagation: jest.fn(),
- };
- wrapper = createComponent({
- ...mockConfig,
- variant: 'standalone',
- });
-
- jest.spyOn(wrapper.vm, 'toggleDropdownContents');
-
- wrapper.vm.handleButtonClick(event);
-
- expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled();
- expect(event.stopPropagation).toHaveBeenCalled();
-
- wrapper.destroy();
- });
+ it.each`
+ variant
+ ${'standalone'}
+ ${'embedded'}
+ `(
+ 'toggles dropdown content and stops event propagation when `state.variant` is "$variant"',
+ ({ variant }) => {
+ const event = { stopPropagation: jest.fn() };
+
+ wrapper = createComponent({
+ ...mockConfig,
+ variant,
+ });
+
+ findDropdownButton().vm.$emit('click', event);
+
+ expect(store.state.showDropdownContents).toBe(true);
+ expect(event.stopPropagation).toHaveBeenCalled();
+ },
+ );
});
});
@@ -61,15 +68,24 @@ describe('DropdownButton', () => {
expect(wrapper.is('gl-button-stub')).toBe(true);
});
- it('renders button text element', () => {
- const dropdownTextEl = wrapper.find('.dropdown-toggle-text');
+ it('renders default button text element', () => {
+ const dropdownTextEl = findDropdownText();
expect(dropdownTextEl.exists()).toBe(true);
expect(dropdownTextEl.text()).toBe('Label');
});
+ it('renders provided button text element', () => {
+ store.state.dropdownButtonText = 'Custom label';
+ const dropdownTextEl = findDropdownText();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(dropdownTextEl.text()).toBe('Custom label');
+ });
+ });
+
it('renders chevron icon element', () => {
- const iconEl = wrapper.find(GlIcon);
+ const iconEl = findDropdownIcon();
expect(iconEl.exists()).toBe(true);
expect(iconEl.props('name')).toBe('chevron-down');
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js
index 1504e1521d3..9b01e0b9637 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js
@@ -44,6 +44,7 @@ const createComponent = (initialState = mockConfig) => {
describe('DropdownContentsLabelsView', () => {
let wrapper;
let wrapperStandalone;
+ let wrapperEmbedded;
beforeEach(() => {
wrapper = createComponent();
@@ -51,11 +52,16 @@ describe('DropdownContentsLabelsView', () => {
...mockConfig,
variant: 'standalone',
});
+ wrapperEmbedded = createComponent({
+ ...mockConfig,
+ variant: 'embedded',
+ });
});
afterEach(() => {
wrapper.destroy();
wrapperStandalone.destroy();
+ wrapperEmbedded.destroy();
});
describe('computed', () => {
@@ -211,6 +217,10 @@ describe('DropdownContentsLabelsView', () => {
expect(wrapperStandalone.find('.dropdown-title').exists()).toBe(false);
});
+ it('renders dropdown title element when `state.variant` is "embedded"', () => {
+ expect(wrapperEmbedded.find('.dropdown-title').exists()).toBe(true);
+ });
+
it('renders dropdown close button element', () => {
const closeButtonEl = wrapper.find('.dropdown-title').find(GlButton);
@@ -291,5 +301,9 @@ describe('DropdownContentsLabelsView', () => {
it('does not render footer list items when `state.variant` is "standalone"', () => {
expect(wrapperStandalone.find('.dropdown-footer').exists()).toBe(false);
});
+
+ it('renders footer list items when `state.variant` is "embedded"', () => {
+ expect(wrapperEmbedded.find('.dropdown-footer').exists()).toBe(true);
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js
index ee4e9090e5d..6e97b046be2 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js
@@ -89,18 +89,23 @@ describe('LabelsSelectRoot', () => {
expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative');
});
- it('renders component root element with CSS class `is-standalone` when `state.variant` is "standalone"', () => {
- const wrapperStandalone = createComponent({
- ...mockConfig,
- variant: 'standalone',
- });
-
- return wrapperStandalone.vm.$nextTick(() => {
- expect(wrapperStandalone.classes()).toContain('is-standalone');
-
- wrapperStandalone.destroy();
- });
- });
+ it.each`
+ variant | cssClass
+ ${'standalone'} | ${'is-standalone'}
+ ${'embedded'} | ${'is-embedded'}
+ `(
+ 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"',
+ ({ variant, cssClass }) => {
+ wrapper = createComponent({
+ ...mockConfig,
+ variant,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.classes()).toContain(cssClass);
+ });
+ },
+ );
it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', () => {
expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true);
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js
index b866117efcf..52116f757c5 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js
@@ -2,13 +2,20 @@ import * as getters from '~/vue_shared/components/sidebar/labels_select_vue/stor
describe('LabelsSelect Getters', () => {
describe('dropdownButtonText', () => {
- it('returns string "Label" when state.labels has no selected labels', () => {
- const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
-
- expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe(
- 'Label',
- );
- });
+ it.each`
+ labelType | dropdownButtonText | expected
+ ${'default'} | ${''} | ${'Label'}
+ ${'custom'} | ${'Custom label'} | ${'Custom label'}
+ `(
+ 'returns $labelType text when state.labels has no selected labels',
+ ({ dropdownButtonText, expected }) => {
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+ const selectedLabels = [];
+ const state = { labels, selectedLabels, dropdownButtonText };
+
+ expect(getters.dropdownButtonText(state, {})).toBe(expected);
+ },
+ );
it('returns label title when state.labels has only 1 label', () => {
const labels = [{ id: 1, title: 'Foobar', set: true }];
diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb
index 4ed63704895..f2757f0e3ed 100644
--- a/spec/helpers/issues_helper_spec.rb
+++ b/spec/helpers/issues_helper_spec.rb
@@ -209,4 +209,28 @@ RSpec.describe IssuesHelper do
it_behaves_like 'does not display link'
end
end
+
+ describe '#show_moved_service_desk_issue_warning?' do
+ let(:project1) { create(:project, service_desk_enabled: true) }
+ let(:project2) { create(:project, service_desk_enabled: true) }
+ let!(:old_issue) { create(:issue, author: User.support_bot, project: project1) }
+ let!(:new_issue) { create(:issue, author: User.support_bot, project: project2) }
+
+ before do
+ allow(Gitlab::IncomingEmail).to receive(:enabled?) { true }
+ allow(Gitlab::IncomingEmail).to receive(:supports_wildcard?) { true }
+
+ old_issue.update(moved_to: new_issue)
+ end
+
+ it 'is true when moved issue project has service desk disabled' do
+ project2.update!(service_desk_enabled: false)
+
+ expect(helper.show_moved_service_desk_issue_warning?(new_issue)).to be(true)
+ end
+
+ it 'is false when moved issue project has service desk enabled' do
+ expect(helper.show_moved_service_desk_issue_warning?(new_issue)).to be(false)
+ end
+ end
end
diff --git a/spec/helpers/tree_helper_spec.rb b/spec/helpers/tree_helper_spec.rb
index 511cfaaa7a9..307479744ef 100644
--- a/spec/helpers/tree_helper_spec.rb
+++ b/spec/helpers/tree_helper_spec.rb
@@ -154,4 +154,58 @@ RSpec.describe TreeHelper do
expect(helper.commit_in_single_accessible_branch).to include(escaped_branch_name)
end
end
+
+ describe '#vue_file_list_data' do
+ before do
+ allow(helper).to receive(:current_user).and_return(nil)
+ end
+
+ it 'returns a list of attributes related to the project' do
+ expect(helper.vue_file_list_data(project, sha)).to include(
+ can_push_code: nil,
+ fork_path: nil,
+ escaped_ref: sha,
+ ref: sha,
+ project_path: project.full_path,
+ project_short_path: project.path,
+ full_name: project.name_with_namespace
+ )
+ end
+
+ context 'user does not have write access but a personal fork exists' do
+ include ProjectForksHelper
+
+ let_it_be(:user) { create(:user) }
+ let!(:forked_project) { create(:project, :repository, namespace: user.namespace) }
+
+ before do
+ project.add_guest(user)
+ fork_project(project, nil, target_project: forked_project)
+
+ allow(helper).to receive(:current_user).and_return(user)
+ end
+
+ it 'includes fork_path too' do
+ expect(helper.vue_file_list_data(project, sha)).to include(
+ fork_path: forked_project.full_path
+ )
+ end
+ end
+
+ context 'user has write access' do
+ let_it_be(:user) { create(:user) }
+
+ before do
+ project.add_developer(user)
+
+ allow(helper).to receive(:current_user).and_return(user)
+ end
+
+ it 'includes can_push_code: true' do
+ expect(helper.vue_file_list_data(project, sha)).to include(
+ can_push_code: "true"
+ )
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/utils_spec.rb b/spec/lib/gitlab/utils_spec.rb
index 4b7a1aa24f4..7a0d40ff0d2 100644
--- a/spec/lib/gitlab/utils_spec.rb
+++ b/spec/lib/gitlab/utils_spec.rb
@@ -358,4 +358,40 @@ RSpec.describe Gitlab::Utils do
})
end
end
+
+ describe '.stable_sort_by' do
+ subject(:sorted_list) { described_class.stable_sort_by(list) { |obj| obj[:priority] } }
+
+ context 'when items have the same priority' do
+ let(:list) do
+ [
+ { name: 'obj 1', priority: 1 },
+ { name: 'obj 2', priority: 1 },
+ { name: 'obj 3', priority: 1 }
+ ]
+ end
+
+ it 'does not change order in cases of ties' do
+ expect(sorted_list).to eq(list)
+ end
+ end
+
+ context 'when items have different priorities' do
+ let(:list) do
+ [
+ { name: 'obj 1', priority: 2 },
+ { name: 'obj 2', priority: 1 },
+ { name: 'obj 3', priority: 3 }
+ ]
+ end
+
+ it 'sorts items like the regular sort_by' do
+ expect(sorted_list).to eq([
+ { name: 'obj 2', priority: 1 },
+ { name: 'obj 1', priority: 2 },
+ { name: 'obj 3', priority: 3 }
+ ])
+ end
+ end
+ end
end
diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb
index 80be5425b23..76b0c04e32d 100644
--- a/spec/requests/api/projects_spec.rb
+++ b/spec/requests/api/projects_spec.rb
@@ -2679,6 +2679,26 @@ RSpec.describe API::Projects do
end
end
end
+
+ context 'when updating service desk' do
+ subject { put(api("/projects/#{project.id}", user), params: { service_desk_enabled: true }) }
+
+ before do
+ project.update!(service_desk_enabled: false)
+
+ allow(::Gitlab::IncomingEmail).to receive(:enabled?).and_return(true)
+ end
+
+ it 'returns 200' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'enables the service_desk' do
+ expect { subject }.to change { project.reload.service_desk_enabled }.to(true)
+ end
+ end
end
describe 'POST /projects/:id/archive' do
diff --git a/spec/requests/projects/incident_management/pagerduty_incidents_spec.rb b/spec/requests/projects/incident_management/pagerduty_incidents_spec.rb
new file mode 100644
index 00000000000..c246aacb4c7
--- /dev/null
+++ b/spec/requests/projects/incident_management/pagerduty_incidents_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'PagerDuty webhook' do
+ let_it_be(:project) { create(:project) }
+
+ describe 'POST /incidents/pagerduty' do
+ let(:payload) { Gitlab::Json.parse(fixture_file('pager_duty/webhook_incident_trigger.json')) }
+ let(:webhook_processor_class) { ::IncidentManagement::PagerDuty::ProcessWebhookService }
+ let(:webhook_processor) { instance_double(webhook_processor_class) }
+
+ def make_request
+ headers = { 'Content-Type' => 'application/json' }
+ post project_incidents_pagerduty_url(project, token: 'VALID-TOKEN'), params: payload.to_json, headers: headers
+ end
+
+ before do
+ allow(webhook_processor_class).to receive(:new).and_return(webhook_processor)
+ allow(webhook_processor).to receive(:execute).and_return(ServiceResponse.success(http_status: :accepted))
+ end
+
+ it 'calls PagerDuty webhook processor with correct parameters' do
+ make_request
+
+ expect(webhook_processor_class).to have_received(:new).with(project, nil, payload)
+ expect(webhook_processor).to have_received(:execute).with('VALID-TOKEN')
+ end
+
+ it 'responds with 202 Accepted' do
+ make_request
+
+ expect(response).to have_gitlab_http_status(:accepted)
+ end
+ end
+end
diff --git a/spec/services/incident_management/pager_duty/process_webhook_service_spec.rb b/spec/services/incident_management/pager_duty/process_webhook_service_spec.rb
new file mode 100644
index 00000000000..11ce8388427
--- /dev/null
+++ b/spec/services/incident_management/pager_duty/process_webhook_service_spec.rb
@@ -0,0 +1,148 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IncidentManagement::PagerDuty::ProcessWebhookService do
+ let_it_be(:project, reload: true) { create(:project) }
+
+ describe '#execute' do
+ shared_examples 'does not process incidents' do
+ it 'does not process incidents' do
+ expect(::IncidentManagement::PagerDuty::ProcessIncidentWorker).not_to receive(:perform_async)
+
+ execute
+ end
+ end
+
+ let(:webhook_payload) { Gitlab::Json.parse(fixture_file('pager_duty/webhook_incident_trigger.json')) }
+ let(:token) { nil }
+
+ subject(:execute) { described_class.new(project, nil, webhook_payload).execute(token) }
+
+ context 'when pagerduty_webhook feature is enabled' do
+ before do
+ stub_feature_flags(pagerduty_webhook: project)
+ end
+
+ context 'when PagerDuty webhook setting is active' do
+ let_it_be(:incident_management_setting) { create(:project_incident_management_setting, project: project, pagerduty_active: true) }
+
+ context 'when token is valid' do
+ let(:token) { incident_management_setting.pagerduty_token }
+
+ context 'when webhook payload has acceptable size' do
+ it 'responds with Accepted' do
+ result = execute
+
+ expect(result).to be_success
+ expect(result.http_status).to eq(:accepted)
+ end
+
+ it 'processes issues' do
+ incident_payload = ::PagerDuty::WebhookPayloadParser.call(webhook_payload).first['incident']
+
+ expect(::IncidentManagement::PagerDuty::ProcessIncidentWorker)
+ .to receive(:perform_async)
+ .with(project.id, incident_payload)
+ .once
+
+ execute
+ end
+ end
+
+ context 'when webhook payload is too big' do
+ let(:deep_size) { instance_double(Gitlab::Utils::DeepSize, valid?: false) }
+
+ before do
+ allow(Gitlab::Utils::DeepSize)
+ .to receive(:new)
+ .with(webhook_payload, max_size: described_class::PAGER_DUTY_PAYLOAD_SIZE_LIMIT)
+ .and_return(deep_size)
+ end
+
+ it 'responds with Bad Request' do
+ result = execute
+
+ expect(result).to be_error
+ expect(result.http_status).to eq(:bad_request)
+ end
+
+ it_behaves_like 'does not process incidents'
+ end
+
+ context 'when webhook payload is blank' do
+ let(:webhook_payload) { nil }
+
+ it 'responds with Accepted' do
+ result = execute
+
+ expect(result).to be_success
+ expect(result.http_status).to eq(:accepted)
+ end
+
+ it_behaves_like 'does not process incidents'
+ end
+ end
+
+ context 'when token is invalid' do
+ let(:token) { 'invalid-token' }
+
+ it 'responds with Unauthorized' do
+ result = execute
+
+ expect(result).to be_error
+ expect(result.http_status).to eq(:unauthorized)
+ end
+
+ it_behaves_like 'does not process incidents'
+ end
+ end
+
+ context 'when both tokens are nil' do
+ let_it_be(:incident_management_setting) { create(:project_incident_management_setting, project: project, pagerduty_active: false) }
+ let(:token) { nil }
+
+ before do
+ incident_management_setting.update_column(:pagerduty_active, true)
+ end
+
+ it 'responds with Unauthorized' do
+ result = execute
+
+ expect(result).to be_error
+ expect(result.http_status).to eq(:unauthorized)
+ end
+
+ it_behaves_like 'does not process incidents'
+ end
+
+ context 'when PagerDuty webhook setting is not active' do
+ let_it_be(:incident_management_setting) { create(:project_incident_management_setting, project: project, pagerduty_active: false) }
+
+ it 'responds with Forbidden' do
+ result = execute
+
+ expect(result).to be_error
+ expect(result.http_status).to eq(:forbidden)
+ end
+
+ it_behaves_like 'does not process incidents'
+ end
+ end
+
+ context 'when pagerduty_webhook feature is disabled' do
+ before do
+ stub_feature_flags(pagerduty_webhook: false)
+ end
+
+ it 'responds with Forbidden' do
+ result = execute
+
+ expect(result).to be_error
+ expect(result.http_status).to eq(:forbidden)
+ end
+
+ it_behaves_like 'does not process incidents'
+ end
+ end
+end
diff --git a/spec/services/merge_requests/create_pipeline_service_spec.rb b/spec/services/merge_requests/create_pipeline_service_spec.rb
index 9100a2fad10..db46bd37eea 100644
--- a/spec/services/merge_requests/create_pipeline_service_spec.rb
+++ b/spec/services/merge_requests/create_pipeline_service_spec.rb
@@ -3,9 +3,12 @@
require 'spec_helper'
RSpec.describe MergeRequests::CreatePipelineService do
+ include ProjectForksHelper
+
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
- let(:service) { described_class.new(project, user, params) }
+ let(:service) { described_class.new(project, actor, params) }
+ let(:actor) { user }
let(:params) { {} }
before do
@@ -26,11 +29,13 @@ RSpec.describe MergeRequests::CreatePipelineService do
let(:merge_request) do
create(:merge_request,
source_branch: 'feature',
- source_project: project,
+ source_project: source_project,
target_branch: 'master',
target_project: project)
end
+ let(:source_project) { project }
+
it 'creates a detached merge request pipeline' do
expect { subject }.to change { Ci::Pipeline.count }.by(1)
@@ -42,6 +47,50 @@ RSpec.describe MergeRequests::CreatePipelineService do
expect(subject.source).to eq('merge_request_event')
end
+ context 'with fork merge request' do
+ let_it_be(:forked_project) { fork_project(project, nil, repository: true, target_project: create(:project, :private, :repository)) }
+ let(:source_project) { forked_project }
+
+ context 'when actor has permission to create pipelines in target project' do
+ let(:actor) { user }
+
+ it 'creates a pipeline in the target project' do
+ expect(subject.project).to eq(project)
+ end
+
+ context 'when ci_allow_to_create_merge_request_pipelines_in_target_project feature flag is disabled' do
+ before do
+ stub_feature_flags(ci_allow_to_create_merge_request_pipelines_in_target_project: false)
+ end
+
+ it 'creates a pipeline in the source project' do
+ expect(subject.project).to eq(source_project)
+ end
+ end
+ end
+
+ context 'when actor has permission to create pipelines in forked project' do
+ let(:actor) { fork_user }
+ let(:fork_user) { create(:user) }
+
+ before do
+ source_project.add_developer(fork_user)
+ end
+
+ it 'creates a pipeline in the source project' do
+ expect(subject.project).to eq(source_project)
+ end
+ end
+
+ context 'when actor does not have permission to create pipelines' do
+ let(:actor) { create(:user) }
+
+ it 'returns nothing' do
+ expect(subject.full_error_messages).to include('Insufficient permissions to create a new pipeline')
+ end
+ end
+ end
+
context 'when service is called multiple times' do
it 'creates a pipeline once' do
expect do
diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb
index 2688c3225fc..a8661f027e8 100644
--- a/spec/services/merge_requests/create_service_spec.rb
+++ b/spec/services/merge_requests/create_service_spec.rb
@@ -216,11 +216,12 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
target_project.add_maintainer(user)
end
- it 'create legacy detached merge request pipeline for fork merge request' do
+ it 'create detached merge request pipeline for fork merge request' do
merge_request.reload
- expect(merge_request.actual_head_pipeline)
- .to be_legacy_detached_merge_request_pipeline
+ head_pipeline = merge_request.actual_head_pipeline
+ expect(head_pipeline).to be_detached_merge_request_pipeline
+ expect(head_pipeline.project).to eq(target_project)
end
end
diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb
index b8f29769429..18c4cef7087 100644
--- a/spec/services/merge_requests/refresh_service_spec.rb
+++ b/spec/services/merge_requests/refresh_service_spec.rb
@@ -225,12 +225,13 @@ RSpec.describe MergeRequests::RefreshService do
context 'when service runs on forked project' do
let(:project) { @fork_project }
- it 'creates legacy detached merge request pipeline for fork merge request', :sidekiq_might_not_need_inline do
+ it 'creates detached merge request pipeline for fork merge request', :sidekiq_inline do
expect { subject }
.to change { @fork_merge_request.pipelines_for_merge_request.count }.by(1)
- expect(@fork_merge_request.pipelines_for_merge_request.first)
- .to be_legacy_detached_merge_request_pipeline
+ merge_request_pipeline = @fork_merge_request.pipelines_for_merge_request.first
+ expect(merge_request_pipeline).to be_detached_merge_request_pipeline
+ expect(merge_request_pipeline.project).to eq(@project)
end
end
diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb
index 79b5ff44d4f..d9a72f2b54a 100644
--- a/spec/support/shared_contexts/navbar_structure_context.rb
+++ b/spec/support/shared_contexts/navbar_structure_context.rb
@@ -42,6 +42,7 @@ RSpec.shared_context 'project navbar structure' do
_('List'),
_('Boards'),
_('Labels'),
+ _('Service Desk'),
_('Milestones')
]
},