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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-05-08 00:10:34 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-05-08 00:10:34 +0300
commit96135034f442825a54cea2812192133d376fbc4b (patch)
tree3cef559fb406d6ea1dda2dc0d315c9e4c71c23d6
parent1572e2a376c0315a0b910bb06e2f2d08cae43adb (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/boards/components/board_filtered_search.vue154
-rw-r--r--app/assets/javascripts/graphql_shared/mutations/dismiss_user_callout.mutation.graphql9
-rw-r--r--app/assets/javascripts/ide/components/file_alert.vue26
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue37
-rw-r--r--app/assets/javascripts/ide/index.js5
-rw-r--r--app/assets/javascripts/ide/lib/alerts/environments.vue32
-rw-r--r--app/assets/javascripts/ide/lib/alerts/index.js20
-rw-r--r--app/assets/javascripts/ide/services/gql.js1
-rw-r--r--app/assets/javascripts/ide/services/index.js16
-rw-r--r--app/assets/javascripts/ide/stores/actions.js3
-rw-r--r--app/assets/javascripts/ide/stores/actions/alert.js18
-rw-r--r--app/assets/javascripts/ide/stores/getters.js2
-rw-r--r--app/assets/javascripts/ide/stores/getters/alert.js3
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js5
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js2
-rw-r--r--app/assets/javascripts/ide/stores/mutations/alert.js21
-rw-r--r--app/assets/javascripts/ide/stores/state.js2
-rw-r--r--app/experiments/in_product_guidance_environments_webide_experiment.rb15
-rw-r--r--app/helpers/ide_helper.rb15
-rw-r--r--app/models/group.rb2
-rw-r--r--app/models/user_callout.rb3
-rw-r--r--app/services/namespaces/in_product_marketing_emails_service.rb14
-rw-r--r--app/services/spam/spam_verdict_service.rb23
-rw-r--r--app/views/projects/_import_project_pane.html.haml2
-rw-r--r--app/views/shared/_import_form.html.haml10
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml2
-rw-r--r--changelogs/unreleased/276953-enforce-git-in-the-url-when-importing-repo-by-url.yml5
-rw-r--r--config/feature_flags/experiment/in_product_guidance_environments_webide.yml8
-rw-r--r--doc/api/graphql/reference/index.md1
-rw-r--r--lib/gitlab/spamcheck/client.rb2
-rw-r--r--locale/gitlab.pot6
-rw-r--r--spec/experiments/in_product_guidance_environments_webide_experiment_spec.rb22
-rw-r--r--spec/features/projects/new_project_spec.rb10
-rw-r--r--spec/frontend/boards/components/board_filtered_search_spec.js146
-rw-r--r--spec/frontend/ide/components/repo_editor_spec.js1
-rw-r--r--spec/frontend/ide/lib/alerts/environment_spec.js21
-rw-r--r--spec/frontend/ide/services/index_spec.js33
-rw-r--r--spec/frontend/ide/stores/actions/alert_spec.js46
-rw-r--r--spec/frontend/ide/stores/actions_spec.js19
-rw-r--r--spec/frontend/ide/stores/getters/alert_spec.js46
-rw-r--r--spec/frontend/ide/stores/mutations/alert_spec.js26
-rw-r--r--spec/helpers/ide_helper_spec.rb30
-rw-r--r--spec/lib/gitlab/spamcheck/client_spec.rb10
-rw-r--r--spec/models/group_spec.rb10
-rw-r--r--spec/services/spam/spam_verdict_service_spec.rb81
45 files changed, 916 insertions, 49 deletions
diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue
new file mode 100644
index 00000000000..e564af0c353
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_filtered_search.vue
@@ -0,0 +1,154 @@
+<script>
+import { pickBy } from 'lodash';
+import { mapActions } from 'vuex';
+import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+
+export default {
+ i18n: {
+ search: __('Search'),
+ label: __('Label'),
+ author: __('Author'),
+ },
+ components: { FilteredSearch },
+ inject: ['initialFilterParams'],
+ props: {
+ tokens: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ filterParams: this.initialFilterParams,
+ };
+ },
+ computed: {
+ urlParams() {
+ const { authorUsername, labelName, search } = this.filterParams;
+ let notParams = {};
+
+ if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) {
+ notParams = pickBy(
+ {
+ 'not[label_name][]': this.filterParams.not.labelName,
+ 'not[author_username]': this.filterParams.not.authorUsername,
+ },
+ undefined,
+ );
+ }
+
+ return {
+ ...notParams,
+ author_username: authorUsername,
+ 'label_name[]': labelName,
+ search,
+ };
+ },
+ },
+ methods: {
+ ...mapActions(['performSearch']),
+ handleFilter(filters) {
+ this.filterParams = this.getFilterParams(filters);
+
+ updateHistory({
+ url: setUrlParams(this.urlParams, window.location.href, true, false, true),
+ title: document.title,
+ replace: true,
+ });
+
+ this.performSearch();
+ },
+ getFilteredSearchValue() {
+ const { authorUsername, labelName, search } = this.filterParams;
+ const filteredSearchValue = [];
+
+ if (authorUsername) {
+ filteredSearchValue.push({
+ type: 'author_username',
+ value: { data: authorUsername, operator: '=' },
+ });
+ }
+
+ if (labelName?.length) {
+ filteredSearchValue.push(
+ ...labelName.map((label) => ({
+ type: 'label_name',
+ value: { data: label, operator: '=' },
+ })),
+ );
+ }
+
+ if (this.filterParams['not[authorUsername]']) {
+ filteredSearchValue.push({
+ type: 'author_username',
+ value: { data: this.filterParams['not[authorUsername]'], operator: '!=' },
+ });
+ }
+
+ if (this.filterParams['not[labelName]']) {
+ filteredSearchValue.push(
+ ...this.filterParams['not[labelName]'].map((label) => ({
+ type: 'label_name',
+ value: { data: label, operator: '!=' },
+ })),
+ );
+ }
+
+ if (search) {
+ filteredSearchValue.push(search);
+ }
+
+ return filteredSearchValue;
+ },
+ getFilterParams(filters = []) {
+ const notFilters = filters.filter((item) => item.value.operator === '!=');
+ const equalsFilters = filters.filter((item) => item.value.operator === '=');
+
+ return { ...this.generateParams(equalsFilters), not: { ...this.generateParams(notFilters) } };
+ },
+ generateParams(filters = []) {
+ const filterParams = {};
+ const labels = [];
+ const plainText = [];
+
+ filters.forEach((filter) => {
+ switch (filter.type) {
+ case 'author_username':
+ filterParams.authorUsername = filter.value.data;
+ break;
+ case 'label_name':
+ labels.push(filter.value.data);
+ break;
+ case 'filtered-search-term':
+ if (filter.value.data) plainText.push(filter.value.data);
+ break;
+ default:
+ break;
+ }
+ });
+
+ if (labels.length) {
+ filterParams.labelName = labels;
+ }
+
+ if (plainText.length) {
+ filterParams.search = plainText.join(' ');
+ }
+ return filterParams;
+ },
+ },
+};
+</script>
+
+<template>
+ <filtered-search
+ class="gl-w-full"
+ namespace=""
+ :tokens="tokens"
+ :search-input-placeholder="$options.i18n.search"
+ :initial-filter-value="getFilteredSearchValue()"
+ @onFilter="handleFilter"
+ />
+</template>
diff --git a/app/assets/javascripts/graphql_shared/mutations/dismiss_user_callout.mutation.graphql b/app/assets/javascripts/graphql_shared/mutations/dismiss_user_callout.mutation.graphql
new file mode 100644
index 00000000000..2b831bf1338
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/mutations/dismiss_user_callout.mutation.graphql
@@ -0,0 +1,9 @@
+mutation dismissUserCallout($input: UserCalloutCreateInput!) {
+ userCalloutCreate(input: $input) {
+ errors
+ userCallout {
+ dismissedAt
+ featureName
+ }
+ }
+}
diff --git a/app/assets/javascripts/ide/components/file_alert.vue b/app/assets/javascripts/ide/components/file_alert.vue
new file mode 100644
index 00000000000..2a894596bf4
--- /dev/null
+++ b/app/assets/javascripts/ide/components/file_alert.vue
@@ -0,0 +1,26 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { getAlert } from '../lib/alerts';
+
+export default {
+ components: {
+ GlAlert,
+ },
+ props: {
+ alertKey: {
+ type: Symbol,
+ required: true,
+ },
+ },
+ computed: {
+ alert() {
+ return getAlert(this.alertKey);
+ },
+ },
+};
+</script>
+<template>
+ <gl-alert v-bind="alert.props" @dismiss="alert.dismiss($store)">
+ <component :is="alert.message" />
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index b57dcd4276c..bf2af9ffd49 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -1,4 +1,5 @@
<script>
+import { debounce } from 'lodash';
import { mapState, mapGetters, mapActions } from 'vuex';
import {
EDITOR_TYPE_DIFF,
@@ -34,11 +35,13 @@ import mapRulesToMonaco from '../lib/editorconfig/rules_mapper';
import { getFileEditorOrDefault } from '../stores/modules/editor/utils';
import { extractMarkdownImagesFromEntries } from '../stores/utils';
import { getPathParent, readFileAsDataURL, registerSchema, isTextFile } from '../utils';
+import FileAlert from './file_alert.vue';
import FileTemplatesBar from './file_templates/bar.vue';
export default {
name: 'RepoEditor',
components: {
+ FileAlert,
ContentViewer,
DiffViewer,
FileTemplatesBar,
@@ -57,6 +60,7 @@ export default {
globalEditor: null,
modelManager: new ModelManager(),
isEditorLoading: true,
+ unwatchCiYaml: null,
};
},
computed: {
@@ -74,6 +78,7 @@ export default {
'currentProjectId',
]),
...mapGetters([
+ 'getAlert',
'currentMergeRequest',
'getStagedFile',
'isEditModeActive',
@@ -82,6 +87,9 @@ export default {
'getJsonSchemaForPath',
]),
...mapGetters('fileTemplates', ['showFileTemplatesBar']),
+ alertKey() {
+ return this.getAlert(this.file);
+ },
fileEditor() {
return getFileEditorOrDefault(this.fileEditors, this.file.path);
},
@@ -136,6 +144,16 @@ export default {
},
},
watch: {
+ 'file.name': {
+ handler() {
+ this.stopWatchingCiYaml();
+
+ if (this.file.name === '.gitlab-ci.yml') {
+ this.startWatchingCiYaml();
+ }
+ },
+ immediate: true,
+ },
file(newVal, oldVal) {
if (oldVal.pending) {
this.removePendingTab(oldVal);
@@ -216,6 +234,7 @@ export default {
'removePendingTab',
'triggerFilesChange',
'addTempImage',
+ 'detectGitlabCiFileAlerts',
]),
...mapActions('editor', ['updateFileEditor']),
initEditor() {
@@ -422,6 +441,18 @@ export default {
this.updateFileEditor({ path: this.file.path, data });
},
+ startWatchingCiYaml() {
+ this.unwatchCiYaml = this.$watch(
+ 'file.content',
+ debounce(this.detectGitlabCiFileAlerts, 500),
+ );
+ },
+ stopWatchingCiYaml() {
+ if (this.unwatchCiYaml) {
+ this.unwatchCiYaml();
+ this.unwatchCiYaml = null;
+ }
+ },
},
viewerTypes,
FILE_VIEW_MODE_EDITOR,
@@ -439,9 +470,8 @@ export default {
role="button"
data-testid="edit-tab"
@click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })"
+ >{{ __('Edit') }}</a
>
- {{ __('Edit') }}
- </a>
</li>
<li v-if="previewMode" :class="previewTabCSS">
<a
@@ -454,7 +484,8 @@ export default {
</li>
</ul>
</div>
- <file-templates-bar v-if="showFileTemplatesBar(file.name)" />
+ <file-alert v-if="alertKey" :alert-key="alertKey" />
+ <file-templates-bar v-else-if="showFileTemplatesBar(file.name)" />
<div
v-show="showEditor"
ref="editor"
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index 2ce5bf7e271..7109c45a3fe 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -56,11 +56,12 @@ export function initIde(el, options = {}) {
webIDEHelpPagePath: el.dataset.webIdeHelpPagePath,
forkInfo: el.dataset.forkInfo ? JSON.parse(el.dataset.forkInfo) : null,
});
- this.setInitialData({
+ this.init({
clientsidePreviewEnabled: parseBoolean(el.dataset.clientsidePreviewEnabled),
renderWhitespaceInCode: parseBoolean(el.dataset.renderWhitespaceInCode),
editorTheme: window.gon?.user_color_scheme || DEFAULT_THEME,
codesandboxBundlerUrl: el.dataset.codesandboxBundlerUrl,
+ environmentsGuidanceAlertDismissed: !parseBoolean(el.dataset.enableEnvironmentsGuidance),
});
},
beforeDestroy() {
@@ -68,7 +69,7 @@ export function initIde(el, options = {}) {
this.$emit('destroy');
},
methods: {
- ...mapActions(['setEmptyStateSvgs', 'setLinks', 'setInitialData']),
+ ...mapActions(['setEmptyStateSvgs', 'setLinks', 'init']),
},
render(createElement) {
return createElement(rootComponent);
diff --git a/app/assets/javascripts/ide/lib/alerts/environments.vue b/app/assets/javascripts/ide/lib/alerts/environments.vue
new file mode 100644
index 00000000000..ac9a3c3f82c
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/alerts/environments.vue
@@ -0,0 +1,32 @@
+<script>
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { __ } from '~/locale';
+
+export default {
+ components: { GlSprintf, GlLink },
+ message: __(
+ "No deployments detected. Use environments to control your software's continuous deployment. %{linkStart}Learn more about deployment jobs.%{linkEnd}",
+ ),
+ computed: {
+ helpLink() {
+ return helpPagePath('ci/environments/index.md');
+ },
+ },
+};
+</script>
+<template>
+ <span>
+ <gl-sprintf :message="$options.message">
+ <template #link="{ content }">
+ <gl-link
+ :href="helpLink"
+ target="_blank"
+ data-track-action="click_link"
+ data-track-experiment="in_product_guidance_environments_webide"
+ >{{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </span>
+</template>
diff --git a/app/assets/javascripts/ide/lib/alerts/index.js b/app/assets/javascripts/ide/lib/alerts/index.js
new file mode 100644
index 00000000000..c9db9779b1f
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/alerts/index.js
@@ -0,0 +1,20 @@
+import { leftSidebarViews } from '../../constants';
+import EnvironmentsMessage from './environments.vue';
+
+const alerts = [
+ {
+ key: Symbol('ALERT_ENVIRONMENT'),
+ show: (state, file) =>
+ state.currentActivityView === leftSidebarViews.commit.name &&
+ file.path === '.gitlab-ci.yml' &&
+ state.environmentsGuidanceAlertDetected &&
+ !state.environmentsGuidanceAlertDismissed,
+ props: { variant: 'tip' },
+ dismiss: ({ dispatch }) => dispatch('dismissEnvironmentsGuidance'),
+ message: EnvironmentsMessage,
+ },
+];
+
+export const findAlertKeyToShow = (...args) => alerts.find((x) => x.show(...args))?.key;
+
+export const getAlert = (key) => alerts.find((x) => x.key === key);
diff --git a/app/assets/javascripts/ide/services/gql.js b/app/assets/javascripts/ide/services/gql.js
index 89dda187360..c8c1031c0f3 100644
--- a/app/assets/javascripts/ide/services/gql.js
+++ b/app/assets/javascripts/ide/services/gql.js
@@ -18,3 +18,4 @@ const getClient = memoize(() =>
);
export const query = (...args) => getClient().query(...args);
+export const mutate = (...args) => getClient().mutate(...args);
diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js
index 0aa08323d13..6bd28cd4fb6 100644
--- a/app/assets/javascripts/ide/services/index.js
+++ b/app/assets/javascripts/ide/services/index.js
@@ -1,8 +1,10 @@
import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql';
import Api from '~/api';
+import dismissUserCallout from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql';
import axios from '~/lib/utils/axios_utils';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
-import { query } from './gql';
+import ciConfig from '~/pipeline_editor/graphql/queries/ci_config.graphql';
+import { query, mutate } from './gql';
const fetchApiProjectData = (projectPath) => Api.project(projectPath).then(({ data }) => data);
@@ -101,4 +103,16 @@ export default {
const url = `${gon.relative_url_root}/${projectPath}/usage_ping/web_ide_pipelines_count`;
return axios.post(url);
},
+ getCiConfig(projectPath, content) {
+ return query({
+ query: ciConfig,
+ variables: { projectPath, content },
+ }).then(({ data }) => data.ciConfig);
+ },
+ dismissUserCallout(name) {
+ return mutate({
+ mutation: dismissUserCallout,
+ variables: { input: { featureName: name } },
+ }).then(({ data }) => data);
+ },
};
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index bf94f9d31c8..062dc150805 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -17,7 +17,7 @@ import * as types from './mutation_types';
export const redirectToUrl = (self, url) => visitUrl(url);
-export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
+export const init = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
export const discardAllChanges = ({ state, commit, dispatch }) => {
state.changedFiles.forEach((file) => dispatch('restoreOriginalFile', file.path));
@@ -316,3 +316,4 @@ export * from './actions/tree';
export * from './actions/file';
export * from './actions/project';
export * from './actions/merge_request';
+export * from './actions/alert';
diff --git a/app/assets/javascripts/ide/stores/actions/alert.js b/app/assets/javascripts/ide/stores/actions/alert.js
new file mode 100644
index 00000000000..4c33dc19520
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/actions/alert.js
@@ -0,0 +1,18 @@
+import service from '../../services';
+import {
+ DETECT_ENVIRONMENTS_GUIDANCE_ALERT,
+ DISMISS_ENVIRONMENTS_GUIDANCE_ALERT,
+} from '../mutation_types';
+
+export const detectGitlabCiFileAlerts = ({ dispatch }, content) =>
+ dispatch('detectEnvironmentsGuidance', content);
+
+export const detectEnvironmentsGuidance = ({ commit, state }, content) =>
+ service.getCiConfig(state.currentProjectId, content).then((data) => {
+ commit(DETECT_ENVIRONMENTS_GUIDANCE_ALERT, data?.stages);
+ });
+
+export const dismissEnvironmentsGuidance = ({ commit }) =>
+ service.dismissUserCallout('web_ide_ci_environments_guidance').then(() => {
+ commit(DISMISS_ENVIRONMENTS_GUIDANCE_ALERT);
+ });
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index e8b1a0ea494..3c02b1d1da7 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -262,3 +262,5 @@ export const getJsonSchemaForPath = (state, getters) => (path) => {
fileMatch: [`*${path}`],
};
};
+
+export * from './getters/alert';
diff --git a/app/assets/javascripts/ide/stores/getters/alert.js b/app/assets/javascripts/ide/stores/getters/alert.js
new file mode 100644
index 00000000000..714e2d89b4f
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/getters/alert.js
@@ -0,0 +1,3 @@
+import { findAlertKeyToShow } from '../../lib/alerts';
+
+export const getAlert = (state) => (file) => findAlertKeyToShow(state, file);
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index 76ba8339703..77755b179ef 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -70,3 +70,8 @@ export const RENAME_ENTRY = 'RENAME_ENTRY';
export const REVERT_RENAME_ENTRY = 'REVERT_RENAME_ENTRY';
export const RESTORE_TREE = 'RESTORE_TREE';
+
+// Alert mutation types
+
+export const DETECT_ENVIRONMENTS_GUIDANCE_ALERT = 'DETECT_ENVIRONMENTS_GUIDANCE_ALERT';
+export const DISMISS_ENVIRONMENTS_GUIDANCE_ALERT = 'DISMISS_ENVIRONMENTS_GUIDANCE_ALERT';
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
index 576f861a090..48648796e66 100644
--- a/app/assets/javascripts/ide/stores/mutations.js
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import * as types from './mutation_types';
+import alertMutations from './mutations/alert';
import branchMutations from './mutations/branch';
import fileMutations from './mutations/file';
import mergeRequestMutation from './mutations/merge_request';
@@ -244,4 +245,5 @@ export default {
...fileMutations,
...treeMutations,
...branchMutations,
+ ...alertMutations,
};
diff --git a/app/assets/javascripts/ide/stores/mutations/alert.js b/app/assets/javascripts/ide/stores/mutations/alert.js
new file mode 100644
index 00000000000..bb2d33a836b
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/mutations/alert.js
@@ -0,0 +1,21 @@
+import {
+ DETECT_ENVIRONMENTS_GUIDANCE_ALERT,
+ DISMISS_ENVIRONMENTS_GUIDANCE_ALERT,
+} from '../mutation_types';
+
+export default {
+ [DETECT_ENVIRONMENTS_GUIDANCE_ALERT](state, stages) {
+ if (!stages) {
+ return;
+ }
+ const hasEnvironments = stages?.nodes?.some((stage) =>
+ stage.groups.nodes.some((group) => group.jobs.nodes.some((job) => job.environment)),
+ );
+ const hasParsedCi = Array.isArray(stages.nodes);
+
+ state.environmentsGuidanceAlertDetected = !hasEnvironments && hasParsedCi;
+ },
+ [DISMISS_ENVIRONMENTS_GUIDANCE_ALERT](state) {
+ state.environmentsGuidanceAlertDismissed = true;
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index c1a83bf0726..83551e87f09 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -30,4 +30,6 @@ export default () => ({
renderWhitespaceInCode: false,
editorTheme: DEFAULT_THEME,
codesandboxBundlerUrl: null,
+ environmentsGuidanceAlertDismissed: false,
+ environmentsGuidanceAlertDetected: false,
});
diff --git a/app/experiments/in_product_guidance_environments_webide_experiment.rb b/app/experiments/in_product_guidance_environments_webide_experiment.rb
new file mode 100644
index 00000000000..d77063a9834
--- /dev/null
+++ b/app/experiments/in_product_guidance_environments_webide_experiment.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class InProductGuidanceEnvironmentsWebideExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
+ exclude :has_environments?
+
+ def control_behavior
+ false
+ end
+
+ private
+
+ def has_environments?
+ !context.project.environments.empty?
+ end
+end
diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb
index 61d8d0f779d..a38ab97e59c 100644
--- a/app/helpers/ide_helper.rb
+++ b/app/helpers/ide_helper.rb
@@ -17,7 +17,8 @@ module IdeHelper
'file-path' => @path,
'merge-request' => @merge_request,
'fork-info' => @fork_info&.to_json,
- 'project' => convert_to_project_entity_json(@project)
+ 'project' => convert_to_project_entity_json(@project),
+ 'enable-environments-guidance' => enable_environments_guidance?.to_s
}
end
@@ -28,6 +29,18 @@ module IdeHelper
API::Entities::Project.represent(project).to_json
end
+
+ def enable_environments_guidance?
+ experiment(:in_product_guidance_environments_webide, project: @project) do |e|
+ e.try { !has_dismissed_ide_environments_callout? }
+
+ e.run
+ end
+ end
+
+ def has_dismissed_ide_environments_callout?
+ current_user.dismissed_callout?(feature_name: 'web_ide_ci_environments_guidance')
+ end
end
::IdeHelper.prepend_if_ee('::EE::IdeHelper')
diff --git a/app/models/group.rb b/app/models/group.rb
index aef5bdd6e88..b9bee1810a6 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -107,6 +107,8 @@ class Group < Namespace
scope :with_users, -> { includes(:users) }
+ scope :with_onboarding_progress, -> { joins(:onboarding_progress) }
+
scope :by_id, ->(groups) { where(id: groups) }
scope :for_authorized_group_members, -> (user_ids) do
diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb
index 852ea05b77f..8fc9efddac9 100644
--- a/app/models/user_callout.rb
+++ b/app/models/user_callout.rb
@@ -31,7 +31,8 @@ class UserCallout < ApplicationRecord
unfinished_tag_cleanup_callout: 27,
eoa_bronze_plan_banner: 28, # EE-only
pipeline_needs_banner: 29,
- pipeline_needs_hover_tip: 30
+ pipeline_needs_hover_tip: 30,
+ web_ide_ci_environments_guidance: 31
}
validates :user, presence: true
diff --git a/app/services/namespaces/in_product_marketing_emails_service.rb b/app/services/namespaces/in_product_marketing_emails_service.rb
index eb81253bc08..3ddb4535bae 100644
--- a/app/services/namespaces/in_product_marketing_emails_service.rb
+++ b/app/services/namespaces/in_product_marketing_emails_service.rb
@@ -66,7 +66,6 @@ module Namespaces
Experiment.add_group(:in_product_marketing_emails, variant: variant, group: group)
end
- # rubocop: disable CodeReuse/ActiveRecord
def groups_for_track
onboarding_progress_scope = OnboardingProgress
.completed_actions_with_latest_in_range(completed_actions, range)
@@ -75,9 +74,18 @@ module Namespaces
# Filtering out sub-groups is a temporary fix to prevent calling
# `.root_ancestor` on groups that are not root groups.
# See https://gitlab.com/groups/gitlab-org/-/epics/5594 for more information.
- Group.where(parent_id: nil).joins(:onboarding_progress).merge(onboarding_progress_scope)
+ Group
+ .top_most
+ .with_onboarding_progress
+ .merge(onboarding_progress_scope)
+ .merge(subscription_scope)
+ end
+
+ def subscription_scope
+ {}
end
+ # rubocop: disable CodeReuse/ActiveRecord
def users_for_group(group)
group.users
.where(email_opted_in: true)
@@ -136,3 +144,5 @@ module Namespaces
end
end
end
+
+Namespaces::InProductMarketingEmailsService.prepend_ee_mod
diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb
index 8dbc590314c..32e58fcc06b 100644
--- a/app/services/spam/spam_verdict_service.rb
+++ b/app/services/spam/spam_verdict_service.rb
@@ -15,11 +15,17 @@ module Spam
def execute
spamcheck_result = nil
+ spamcheck_attribs = {}
external_spam_check_round_trip_time = Benchmark.realtime do
- spamcheck_result = spamcheck_verdict
+ spamcheck_result, spamcheck_attribs = spamcheck_verdict
end
+ # assign result to a var and log it before reassigning to nil when monitorMode is true
+ original_spamcheck_result = spamcheck_result
+
+ spamcheck_result = nil if spamcheck_attribs&.fetch("monitorMode", "false") == "true"
+
akismet_result = akismet_verdict
# filter out anything we don't recognise, including nils.
@@ -33,7 +39,8 @@ module Spam
logger.info(class: self.class.name,
akismet_verdict: akismet_verdict,
- spam_check_verdict: spamcheck_result,
+ spam_check_verdict: original_spamcheck_result,
+ extra_attributes: spamcheck_attribs,
spam_check_rtt: external_spam_check_round_trip_time.real,
final_verdict: final_verdict,
username: user.username,
@@ -61,21 +68,23 @@ module Spam
return unless Gitlab::CurrentSettings.spam_check_endpoint_enabled
begin
- result, _error = spamcheck_client.issue_spam?(spam_issue: target, user: user, context: context)
- return unless result
+ result, attribs, _error = spamcheck_client.issue_spam?(spam_issue: target, user: user, context: context)
+ return [nil, attribs] unless result
# @TODO log if error is not nil https://gitlab.com/gitlab-org/gitlab/-/issues/329545
+ return [result, attribs] if result == NOOP || attribs["monitorMode"] == "true"
+
# Duplicate logic with Akismet logic in #akismet_verdict
if Gitlab::Recaptcha.enabled? && result != ALLOW
- CONDITIONAL_ALLOW
+ [CONDITIONAL_ALLOW, attribs]
else
- result
+ [result, attribs]
end
rescue StandardError => e
Gitlab::ErrorTracking.log_exception(e)
# Default to ALLOW if any errors occur
- ALLOW
+ [ALLOW, attribs]
end
end
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index e6ded3ad912..c0fe788b56a 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -83,7 +83,7 @@
.js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') }
- = form_for @project, html: { class: 'new_project' } do |f|
+ = form_for @project, html: { class: 'new_project gl-show-field-errors' } do |f|
%hr
= render "shared/import_form", f: f
= render 'projects/new_project_fields', f: f, project_name_id: "import-url-name", hide_init_with_readme: true, track_label: track_label
diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml
index 65e02341936..cf9ee1a5231 100644
--- a/app/views/shared/_import_form.html.haml
+++ b/app/views/shared/_import_form.html.haml
@@ -6,8 +6,14 @@
= f.label :import_url, class: 'label-bold' do
%span
= _('Git repository URL')
- = f.text_field :import_url, value: import_url.sanitized_url,
- autocomplete: 'off', class: 'form-control gl-form-input', placeholder: 'https://gitlab.company.com/group/project.git', required: true
+ = f.text_field :import_url,
+ value: import_url.sanitized_url,
+ autocomplete: 'off',
+ class: 'form-control gl-form-input',
+ placeholder: 'https://gitlab.company.com/group/project.git',
+ required: true,
+ pattern: '(?:git|https?):\/\/.*/.*\.git$',
+ title: _('Please provide a valid URL ending with .git')
.row
.form-group.col-md-6
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index 1e340f033a1..831fe784acf 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -22,7 +22,7 @@
.check-all-holder.d-none.d-sm-block.hidden
= check_box_tag "check-all-issues", nil, false, class: "check-all-issues left"
- if Feature.enabled?(:boards_filtered_search, @group) && is_epic_board
- #js-board-filtered-search
+ #js-board-filtered-search{ data: { full_path: @group&.full_path } }
- else
.issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row
.filtered-search-box
diff --git a/changelogs/unreleased/276953-enforce-git-in-the-url-when-importing-repo-by-url.yml b/changelogs/unreleased/276953-enforce-git-in-the-url-when-importing-repo-by-url.yml
new file mode 100644
index 00000000000..98ce550e073
--- /dev/null
+++ b/changelogs/unreleased/276953-enforce-git-in-the-url-when-importing-repo-by-url.yml
@@ -0,0 +1,5 @@
+---
+title: Enforce .git suffix when importing git repo
+merge_request: 61115
+author:
+type: changed
diff --git a/config/feature_flags/experiment/in_product_guidance_environments_webide.yml b/config/feature_flags/experiment/in_product_guidance_environments_webide.yml
new file mode 100644
index 00000000000..4eaf6e90b27
--- /dev/null
+++ b/config/feature_flags/experiment/in_product_guidance_environments_webide.yml
@@ -0,0 +1,8 @@
+---
+name: in_product_guidance_environments_webide
+introduced_by_url:
+rollout_issue_url:
+milestone: '13.12'
+type: experiment
+group: group::release
+default_enabled: false
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 565f2da07eb..a1c397890ef 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -14418,6 +14418,7 @@ Name of the feature that the callout is for.
| <a id="usercalloutfeaturenameenumunfinished_tag_cleanup_callout"></a>`UNFINISHED_TAG_CLEANUP_CALLOUT` | Callout feature name for unfinished_tag_cleanup_callout. |
| <a id="usercalloutfeaturenameenumwebhooks_moved"></a>`WEBHOOKS_MOVED` | Callout feature name for webhooks_moved. |
| <a id="usercalloutfeaturenameenumweb_ide_alert_dismissed"></a>`WEB_IDE_ALERT_DISMISSED` | Callout feature name for web_ide_alert_dismissed. |
+| <a id="usercalloutfeaturenameenumweb_ide_ci_environments_guidance"></a>`WEB_IDE_CI_ENVIRONMENTS_GUIDANCE` | Callout feature name for web_ide_ci_environments_guidance. |
### `UserState`
diff --git a/lib/gitlab/spamcheck/client.rb b/lib/gitlab/spamcheck/client.rb
index e4dfb3da0f3..6afc21be4e0 100644
--- a/lib/gitlab/spamcheck/client.rb
+++ b/lib/gitlab/spamcheck/client.rb
@@ -45,7 +45,7 @@ module Gitlab
metadata: { 'authorization' =>
Gitlab::CurrentSettings.spam_check_api_key })
verdict = convert_verdict_to_gitlab_constant(response.verdict)
- [verdict, response.error]
+ [verdict, response.extra_attributes.to_h, response.error]
end
private
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 93235d47b2d..55356dba1b6 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -22044,6 +22044,9 @@ msgstr ""
msgid "No data to display"
msgstr ""
+msgid "No deployments detected. Use environments to control your software's continuous deployment. %{linkStart}Learn more about deployment jobs.%{linkEnd}"
+msgstr ""
+
msgid "No deployments found"
msgstr ""
@@ -24322,6 +24325,9 @@ msgstr ""
msgid "Please provide a valid URL"
msgstr ""
+msgid "Please provide a valid URL ending with .git"
+msgstr ""
+
msgid "Please provide a valid YouTube URL or ID"
msgstr ""
diff --git a/spec/experiments/in_product_guidance_environments_webide_experiment_spec.rb b/spec/experiments/in_product_guidance_environments_webide_experiment_spec.rb
new file mode 100644
index 00000000000..d616672173e
--- /dev/null
+++ b/spec/experiments/in_product_guidance_environments_webide_experiment_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe InProductGuidanceEnvironmentsWebideExperiment, :experiment do
+ subject { described_class.new(project: project) }
+
+ let(:project) { create(:project, :repository) }
+
+ before do
+ stub_experiments(in_product_guidance_environments_webide: :candidate)
+ end
+
+ it 'excludes projects with environments' do
+ create(:environment, project: project)
+ expect(subject).to exclude(project: project)
+ end
+
+ it 'does not exlude projects without environments' do
+ expect(subject).not_to exclude(project: project)
+ end
+end
diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb
index e1fbe2da9f1..ba28338cae3 100644
--- a/spec/features/projects/new_project_spec.rb
+++ b/spec/features/projects/new_project_spec.rb
@@ -355,6 +355,16 @@ RSpec.describe 'New project', :js do
expect(git_import_instructions).to have_content 'Git repository URL'
end
+ it 'reports error if repo URL does not end with .git' do
+ fill_in 'project_import_url', with: 'http://foo/bar'
+ fill_in 'project_name', with: 'import-project-without-git-suffix'
+ fill_in 'project_path', with: 'import-project-without-git-suffix'
+
+ click_button 'Create project'
+
+ expect(page).to have_text('Please provide a valid URL ending with .git')
+ end
+
it 'keeps "Import project" tab open after form validation error' do
collision_project = create(:project, name: 'test-name-collision', namespace: user.namespace)
diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js
new file mode 100644
index 00000000000..e27badca9de
--- /dev/null
+++ b/spec/frontend/boards/components/board_filtered_search_spec.js
@@ -0,0 +1,146 @@
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import Vuex from 'vuex';
+import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue';
+import { createStore } from '~/boards/stores';
+import * as urlUtility from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
+import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
+
+Vue.use(Vuex);
+
+describe('BoardFilteredSearch', () => {
+ let wrapper;
+ let store;
+ const tokens = [
+ {
+ icon: 'labels',
+ title: __('Label'),
+ type: 'label_name',
+ operators: [
+ { value: '=', description: 'is' },
+ { value: '!=', description: 'is not' },
+ ],
+ token: LabelToken,
+ unique: false,
+ symbol: '~',
+ fetchLabels: () => new Promise(() => {}),
+ },
+ {
+ icon: 'pencil',
+ title: __('Author'),
+ type: 'author_username',
+ operators: [
+ { value: '=', description: 'is' },
+ { value: '!=', description: 'is not' },
+ ],
+ symbol: '@',
+ token: AuthorToken,
+ unique: true,
+ fetchAuthors: () => new Promise(() => {}),
+ },
+ ];
+
+ const createComponent = ({ initialFilterParams = {} } = {}) => {
+ wrapper = shallowMount(BoardFilteredSearch, {
+ provide: { initialFilterParams, fullPath: '' },
+ store,
+ propsData: {
+ tokens,
+ },
+ });
+ };
+
+ const findFilteredSearch = () => wrapper.findComponent(FilteredSearchBarRoot);
+
+ beforeEach(() => {
+ // this needed for actions call for performSearch
+ window.gon = { features: {} };
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('default', () => {
+ beforeEach(() => {
+ store = createStore();
+
+ jest.spyOn(store, 'dispatch');
+
+ createComponent();
+ });
+
+ it('renders FilteredSearch', () => {
+ expect(findFilteredSearch().exists()).toBe(true);
+ });
+
+ it('passes the correct tokens to FilteredSearch', () => {
+ expect(findFilteredSearch().props('tokens')).toEqual(tokens);
+ });
+
+ describe('when onFilter is emitted', () => {
+ it('calls performSearch', () => {
+ findFilteredSearch().vm.$emit('onFilter', [{ value: { data: '' } }]);
+
+ expect(store.dispatch).toHaveBeenCalledWith('performSearch');
+ });
+
+ it('calls historyPushState', () => {
+ jest.spyOn(urlUtility, 'updateHistory');
+ findFilteredSearch().vm.$emit('onFilter', [{ value: { data: 'searchQuery' } }]);
+
+ expect(urlUtility.updateHistory).toHaveBeenCalledWith({
+ replace: true,
+ title: '',
+ url: 'http://test.host/',
+ });
+ });
+ });
+ });
+
+ describe('when searching', () => {
+ beforeEach(() => {
+ store = createStore();
+
+ jest.spyOn(store, 'dispatch');
+
+ createComponent();
+ });
+
+ it('sets the url params to the correct results', async () => {
+ const mockFilters = [
+ { type: 'author_username', value: { data: 'root', operator: '=' } },
+ { type: 'label_name', value: { data: 'label', operator: '=' } },
+ { type: 'label_name', value: { data: 'label2', operator: '=' } },
+ ];
+ jest.spyOn(urlUtility, 'updateHistory');
+ findFilteredSearch().vm.$emit('onFilter', mockFilters);
+
+ expect(urlUtility.updateHistory).toHaveBeenCalledWith({
+ title: '',
+ replace: true,
+ url: 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label2',
+ });
+ });
+ });
+
+ describe('when url params are already set', () => {
+ beforeEach(() => {
+ store = createStore();
+
+ jest.spyOn(store, 'dispatch');
+
+ createComponent({ initialFilterParams: { authorUsername: 'root', labelName: ['label'] } });
+ });
+
+ it('passes the correct props to FilterSearchBar', () => {
+ expect(findFilteredSearch().props('initialFilterValue')).toEqual([
+ { type: 'author_username', value: { data: 'root', operator: '=' } },
+ { type: 'label_name', value: { data: 'label', operator: '=' } },
+ ]);
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js
index a3b327343e5..646e51160d8 100644
--- a/spec/frontend/ide/components/repo_editor_spec.js
+++ b/spec/frontend/ide/components/repo_editor_spec.js
@@ -510,6 +510,7 @@ describe('RepoEditor', () => {
},
});
await vm.$nextTick();
+ await vm.$nextTick();
expect(vm.initEditor).toHaveBeenCalled();
});
diff --git a/spec/frontend/ide/lib/alerts/environment_spec.js b/spec/frontend/ide/lib/alerts/environment_spec.js
new file mode 100644
index 00000000000..d645209345c
--- /dev/null
+++ b/spec/frontend/ide/lib/alerts/environment_spec.js
@@ -0,0 +1,21 @@
+import { GlLink } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import Environments from '~/ide/lib/alerts/environments.vue';
+
+describe('~/ide/lib/alerts/environment.vue', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = mount(Environments);
+ });
+
+ it('shows a message regarding environments', () => {
+ expect(wrapper.text()).toBe(
+ "No deployments detected. Use environments to control your software's continuous deployment. Learn more about deployment jobs.",
+ );
+ });
+
+ it('links to the help page on environments', () => {
+ expect(wrapper.findComponent(GlLink).attributes('href')).toBe('/help/ci/environments/index.md');
+ });
+});
diff --git a/spec/frontend/ide/services/index_spec.js b/spec/frontend/ide/services/index_spec.js
index 3503834e24b..4a726cff3b6 100644
--- a/spec/frontend/ide/services/index_spec.js
+++ b/spec/frontend/ide/services/index_spec.js
@@ -2,9 +2,11 @@ import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql';
import Api from '~/api';
+import dismissUserCallout from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql';
import services from '~/ide/services';
-import { query } from '~/ide/services/gql';
+import { query, mutate } from '~/ide/services/gql';
import { escapeFileUrl } from '~/lib/utils/url_utility';
+import ciConfig from '~/pipeline_editor/graphql/queries/ci_config.graphql';
import { projectData } from '../mock_data';
jest.mock('~/api');
@@ -299,4 +301,33 @@ describe('IDE services', () => {
});
});
});
+ describe('getCiConfig', () => {
+ const TEST_PROJECT_PATH = 'foo/bar';
+ const TEST_CI_CONFIG = 'test config';
+
+ it('queries with the given CI config and project', () => {
+ const result = { data: { ciConfig: { test: 'data' } } };
+ query.mockResolvedValue(result);
+ return services.getCiConfig(TEST_PROJECT_PATH, TEST_CI_CONFIG).then((data) => {
+ expect(data).toEqual(result.data.ciConfig);
+ expect(query).toHaveBeenCalledWith({
+ query: ciConfig,
+ variables: { projectPath: TEST_PROJECT_PATH, content: TEST_CI_CONFIG },
+ });
+ });
+ });
+ });
+ describe('dismissUserCallout', () => {
+ it('mutates the callout to dismiss', () => {
+ const result = { data: { callouts: { test: 'data' } } };
+ mutate.mockResolvedValue(result);
+ return services.dismissUserCallout('test').then((data) => {
+ expect(data).toEqual(result.data);
+ expect(mutate).toHaveBeenCalledWith({
+ mutation: dismissUserCallout,
+ variables: { input: { featureName: 'test' } },
+ });
+ });
+ });
+ });
});
diff --git a/spec/frontend/ide/stores/actions/alert_spec.js b/spec/frontend/ide/stores/actions/alert_spec.js
new file mode 100644
index 00000000000..1321c402ebb
--- /dev/null
+++ b/spec/frontend/ide/stores/actions/alert_spec.js
@@ -0,0 +1,46 @@
+import testAction from 'helpers/vuex_action_helper';
+import service from '~/ide/services';
+import {
+ detectEnvironmentsGuidance,
+ dismissEnvironmentsGuidance,
+} from '~/ide/stores/actions/alert';
+import * as types from '~/ide/stores/mutation_types';
+
+jest.mock('~/ide/services');
+
+describe('~/ide/stores/actions/alert', () => {
+ describe('detectEnvironmentsGuidance', () => {
+ it('should try to fetch CI info', () => {
+ const stages = ['a', 'b', 'c'];
+ service.getCiConfig.mockResolvedValue({ stages });
+
+ return testAction(
+ detectEnvironmentsGuidance,
+ 'the content',
+ { currentProjectId: 'gitlab/test' },
+ [{ type: types.DETECT_ENVIRONMENTS_GUIDANCE_ALERT, payload: stages }],
+ [],
+ () => expect(service.getCiConfig).toHaveBeenCalledWith('gitlab/test', 'the content'),
+ );
+ });
+ });
+ describe('dismissCallout', () => {
+ it('should try to dismiss the given callout', () => {
+ const callout = { featureName: 'test', dismissedAt: 'now' };
+
+ service.dismissUserCallout.mockResolvedValue({ userCalloutCreate: { userCallout: callout } });
+
+ return testAction(
+ dismissEnvironmentsGuidance,
+ undefined,
+ {},
+ [{ type: types.DISMISS_ENVIRONMENTS_GUIDANCE_ALERT }],
+ [],
+ () =>
+ expect(service.dismissUserCallout).toHaveBeenCalledWith(
+ 'web_ide_ci_environments_guidance',
+ ),
+ );
+ });
+ });
+});
diff --git a/spec/frontend/ide/stores/actions_spec.js b/spec/frontend/ide/stores/actions_spec.js
index d47dd88dd47..ad55313da93 100644
--- a/spec/frontend/ide/stores/actions_spec.js
+++ b/spec/frontend/ide/stores/actions_spec.js
@@ -4,6 +4,7 @@ import eventHub from '~/ide/eventhub';
import { createRouter } from '~/ide/ide_router';
import { createStore } from '~/ide/stores';
import {
+ init,
stageAllChanges,
unstageAllChanges,
toggleFileFinder,
@@ -54,15 +55,15 @@ describe('Multi-file store actions', () => {
});
});
- describe('setInitialData', () => {
- it('commits initial data', (done) => {
- store
- .dispatch('setInitialData', { canCommit: true })
- .then(() => {
- expect(store.state.canCommit).toBeTruthy();
- done();
- })
- .catch(done.fail);
+ describe('init', () => {
+ it('commits initial data and requests user callouts', () => {
+ return testAction(
+ init,
+ { canCommit: true },
+ store.state,
+ [{ type: 'SET_INITIAL_DATA', payload: { canCommit: true } }],
+ [],
+ );
});
});
diff --git a/spec/frontend/ide/stores/getters/alert_spec.js b/spec/frontend/ide/stores/getters/alert_spec.js
new file mode 100644
index 00000000000..7068b8e637f
--- /dev/null
+++ b/spec/frontend/ide/stores/getters/alert_spec.js
@@ -0,0 +1,46 @@
+import { getAlert } from '~/ide/lib/alerts';
+import EnvironmentsMessage from '~/ide/lib/alerts/environments.vue';
+import { createStore } from '~/ide/stores';
+import * as getters from '~/ide/stores/getters/alert';
+import { file } from '../../helpers';
+
+describe('IDE store alert getters', () => {
+ let localState;
+ let localStore;
+
+ beforeEach(() => {
+ localStore = createStore();
+ localState = localStore.state;
+ });
+
+ describe('alerts', () => {
+ describe('shows an alert about environments', () => {
+ let alert;
+
+ beforeEach(() => {
+ const f = file('.gitlab-ci.yml');
+ localState.openFiles.push(f);
+ localState.currentActivityView = 'repo-commit-section';
+ localState.environmentsGuidanceAlertDetected = true;
+ localState.environmentsGuidanceAlertDismissed = false;
+
+ const alertKey = getters.getAlert(localState)(f);
+ alert = getAlert(alertKey);
+ });
+
+ it('has a message suggesting to use environments', () => {
+ expect(alert.message).toEqual(EnvironmentsMessage);
+ });
+
+ it('dispatches to dismiss the callout on dismiss', () => {
+ jest.spyOn(localStore, 'dispatch').mockImplementation();
+ alert.dismiss(localStore);
+ expect(localStore.dispatch).toHaveBeenCalledWith('dismissEnvironmentsGuidance');
+ });
+
+ it('should be a tip alert', () => {
+ expect(alert.props).toEqual({ variant: 'tip' });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/stores/mutations/alert_spec.js b/spec/frontend/ide/stores/mutations/alert_spec.js
new file mode 100644
index 00000000000..2840ec4ebb7
--- /dev/null
+++ b/spec/frontend/ide/stores/mutations/alert_spec.js
@@ -0,0 +1,26 @@
+import * as types from '~/ide/stores/mutation_types';
+import mutations from '~/ide/stores/mutations/alert';
+
+describe('~/ide/stores/mutations/alert', () => {
+ const state = {};
+
+ describe(types.DETECT_ENVIRONMENTS_GUIDANCE_ALERT, () => {
+ it('checks the stages for any that configure environments', () => {
+ mutations[types.DETECT_ENVIRONMENTS_GUIDANCE_ALERT](state, {
+ nodes: [{ groups: { nodes: [{ jobs: { nodes: [{}] } }] } }],
+ });
+ expect(state.environmentsGuidanceAlertDetected).toBe(true);
+ mutations[types.DETECT_ENVIRONMENTS_GUIDANCE_ALERT](state, {
+ nodes: [{ groups: { nodes: [{ jobs: { nodes: [{ environment: {} }] } }] } }],
+ });
+ expect(state.environmentsGuidanceAlertDetected).toBe(false);
+ });
+ });
+
+ describe(types.DISMISS_ENVIRONMENTS_GUIDANCE_ALERT, () => {
+ it('stops environments guidance', () => {
+ mutations[types.DISMISS_ENVIRONMENTS_GUIDANCE_ALERT](state);
+ expect(state.environmentsGuidanceAlertDismissed).toBe(true);
+ });
+ });
+});
diff --git a/spec/helpers/ide_helper_spec.rb b/spec/helpers/ide_helper_spec.rb
index 963d5953d4c..d34358e49c0 100644
--- a/spec/helpers/ide_helper_spec.rb
+++ b/spec/helpers/ide_helper_spec.rb
@@ -45,5 +45,35 @@ RSpec.describe IdeHelper do
)
end
end
+
+ context 'environments guidance experiment', :experiment do
+ before do
+ stub_experiments(in_product_guidance_environments_webide: :candidate)
+ self.instance_variable_set(:@project, project)
+ end
+
+ context 'when project has no enviornments' do
+ it 'enables environment guidance' do
+ expect(helper.ide_data).to include('enable-environments-guidance' => 'true')
+ end
+
+ context 'and the callout has been dismissed' do
+ it 'disables environment guidance' do
+ callout = create(:user_callout, feature_name: :web_ide_ci_environments_guidance, user: project.creator)
+ callout.update!(dismissed_at: Time.now - 1.week)
+ allow(helper).to receive(:current_user).and_return(User.find(project.creator.id))
+ expect(helper.ide_data).to include('enable-environments-guidance' => 'false')
+ end
+ end
+ end
+
+ context 'when the project has environments' do
+ it 'disables environment guidance' do
+ create(:environment, project: project)
+
+ expect(helper.ide_data).to include('enable-environments-guidance' => 'false')
+ end
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/spamcheck/client_spec.rb b/spec/lib/gitlab/spamcheck/client_spec.rb
index 3384c079ffc..491e5e9a662 100644
--- a/spec/lib/gitlab/spamcheck/client_spec.rb
+++ b/spec/lib/gitlab/spamcheck/client_spec.rb
@@ -9,12 +9,20 @@ RSpec.describe Gitlab::Spamcheck::Client do
let_it_be(:user) { create(:user, organization: 'GitLab') }
let(:verdict_value) { nil }
let(:error_value) { "" }
+
+ let(:attribs_value) do
+ extra_attributes = Google::Protobuf::Map.new(:string, :string)
+ extra_attributes["monitorMode"] = "false"
+ extra_attributes
+ end
+
let_it_be(:issue) { create(:issue, description: 'Test issue description') }
let(:response) do
verdict = ::Spamcheck::SpamVerdict.new
verdict.verdict = verdict_value
verdict.error = error_value
+ verdict.extra_attributes = attribs_value
verdict
end
@@ -45,7 +53,7 @@ RSpec.describe Gitlab::Spamcheck::Client do
let(:verdict_value) { verdict }
it "returns expected spam constant" do
- expect(subject).to eq([expected, ""])
+ expect(subject).to eq([expected, { "monitorMode" => "false" }, ""])
end
end
end
diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb
index 7334165c41a..1460fe0e586 100644
--- a/spec/models/group_spec.rb
+++ b/spec/models/group_spec.rb
@@ -632,6 +632,16 @@ RSpec.describe Group do
it { is_expected.to match_array([private_group, internal_group]) }
end
+ describe 'with_onboarding_progress' do
+ subject { described_class.with_onboarding_progress }
+
+ it 'joins onboarding_progress' do
+ create(:onboarding_progress, namespace: group)
+
+ expect(subject).to eq([group])
+ end
+ end
+
describe 'for_authorized_group_members' do
let_it_be(:group_member1) { create(:group_member, source: private_group, user_id: user1.id, access_level: Gitlab::Access::OWNER) }
diff --git a/spec/services/spam/spam_verdict_service_spec.rb b/spec/services/spam/spam_verdict_service_spec.rb
index 1ec5e1f1331..91bff49b239 100644
--- a/spec/services/spam/spam_verdict_service_spec.rb
+++ b/spec/services/spam/spam_verdict_service_spec.rb
@@ -23,12 +23,17 @@ RSpec.describe Spam::SpamVerdictService do
described_class.new(user: user, target: issue, request: request, options: {})
end
+ let(:attribs) do
+ extra_attributes = { "monitorMode" => "false" }
+ extra_attributes
+ end
+
describe '#execute' do
subject { service.execute }
before do
allow(service).to receive(:akismet_verdict).and_return(nil)
- allow(service).to receive(:spamcheck_verdict).and_return(nil)
+ allow(service).to receive(:spamcheck_verdict).and_return([nil, attribs])
end
context 'if all services return nil' do
@@ -63,7 +68,7 @@ RSpec.describe Spam::SpamVerdictService do
context 'and they are supported' do
before do
allow(service).to receive(:akismet_verdict).and_return(DISALLOW)
- allow(service).to receive(:spamcheck_verdict).and_return(BLOCK_USER)
+ allow(service).to receive(:spamcheck_verdict).and_return([BLOCK_USER, attribs])
end
it 'renders the more restrictive verdict' do
@@ -74,7 +79,7 @@ RSpec.describe Spam::SpamVerdictService do
context 'and one is supported' do
before do
allow(service).to receive(:akismet_verdict).and_return('nonsense')
- allow(service).to receive(:spamcheck_verdict).and_return(BLOCK_USER)
+ allow(service).to receive(:spamcheck_verdict).and_return([BLOCK_USER, attribs])
end
it 'renders the more restrictive verdict' do
@@ -85,13 +90,29 @@ RSpec.describe Spam::SpamVerdictService do
context 'and none are supported' do
before do
allow(service).to receive(:akismet_verdict).and_return('nonsense')
- allow(service).to receive(:spamcheck_verdict).and_return('rubbish')
+ allow(service).to receive(:spamcheck_verdict).and_return(['rubbish', attribs])
end
it 'renders the more restrictive verdict' do
expect(subject).to eq ALLOW
end
end
+
+ context 'and attribs - monitorMode is true' do
+ let(:attribs) do
+ extra_attributes = { "monitorMode" => "true" }
+ extra_attributes
+ end
+
+ before do
+ allow(service).to receive(:akismet_verdict).and_return(DISALLOW)
+ allow(service).to receive(:spamcheck_verdict).and_return([BLOCK_USER, attribs])
+ end
+
+ it 'renders the more restrictive verdict' do
+ expect(subject).to eq(DISALLOW)
+ end
+ end
end
end
@@ -170,16 +191,42 @@ RSpec.describe Spam::SpamVerdictService do
let(:error) { '' }
let(:verdict) { nil }
+ let(:attribs) do
+ extra_attributes = { "monitorMode" => "false" }
+ extra_attributes
+ end
+
before do
allow(service).to receive(:spamcheck_client).and_return(spam_client)
- allow(spam_client).to receive(:issue_spam?).and_return([verdict, error])
+ allow(spam_client).to receive(:issue_spam?).and_return([verdict, attribs, error])
+ end
+
+ context 'if the result is a NOOP verdict' do
+ let(:verdict) { NOOP }
+
+ it 'returns the verdict' do
+ expect(subject).to eq([NOOP, attribs])
+ end
+ end
+
+ context 'if attribs - monitorMode is true' do
+ let(:attribs) do
+ extra_attributes = { "monitorMode" => "true" }
+ extra_attributes
+ end
+
+ let(:verdict) { ALLOW }
+
+ it 'returns the verdict' do
+ expect(subject).to eq([ALLOW, attribs])
+ end
end
context 'the result is a valid verdict' do
let(:verdict) { ALLOW }
it 'returns the verdict' do
- expect(subject).to eq ALLOW
+ expect(subject).to eq([ALLOW, attribs])
end
end
@@ -203,7 +250,7 @@ RSpec.describe Spam::SpamVerdictService do
let(:verdict) { verdict_value }
it "returns expected spam constant" do
- expect(subject).to eq(expected)
+ expect(subject).to eq([expected, attribs])
end
end
end
@@ -218,7 +265,7 @@ RSpec.describe Spam::SpamVerdictService do
::Spam::SpamConstants::DISALLOW,
::Spam::SpamConstants::BLOCK_USER].each do |verdict_value|
let(:verdict) { verdict_value }
- let(:expected) { verdict_value }
+ let(:expected) { [verdict_value, attribs] }
it "returns expected spam constant" do
expect(subject).to eq(expected)
@@ -230,7 +277,7 @@ RSpec.describe Spam::SpamVerdictService do
let(:verdict) { :this_is_fine }
it 'returns the string' do
- expect(subject).to eq verdict
+ expect(subject).to eq([verdict, attribs])
end
end
@@ -238,7 +285,7 @@ RSpec.describe Spam::SpamVerdictService do
let(:verdict) { '' }
it 'returns nil' do
- expect(subject).to eq verdict
+ expect(subject).to eq([verdict, attribs])
end
end
@@ -246,7 +293,7 @@ RSpec.describe Spam::SpamVerdictService do
let(:verdict) { nil }
it 'returns nil' do
- expect(subject).to be_nil
+ expect(subject).to eq([nil, attribs])
end
end
@@ -254,17 +301,19 @@ RSpec.describe Spam::SpamVerdictService do
let(:error) { "Sorry Dave, I can't do that" }
it 'returns nil' do
- expect(subject).to be_nil
+ expect(subject).to eq([nil, attribs])
end
end
context 'the requested is aborted' do
+ let(:attribs) { nil }
+
before do
allow(spam_client).to receive(:issue_spam?).and_raise(GRPC::Aborted)
end
it 'returns nil' do
- expect(subject).to be(ALLOW)
+ expect(subject).to eq([ALLOW, attribs])
end
end
@@ -273,18 +322,20 @@ RSpec.describe Spam::SpamVerdictService do
let(:error) { 'oh noes!' }
it 'renders the verdict' do
- expect(subject).to eq DISALLOW
+ expect(subject).to eq [DISALLOW, attribs]
end
end
end
context 'if the endpoint times out' do
+ let(:attribs) { nil }
+
before do
allow(spam_client).to receive(:issue_spam?).and_raise(GRPC::DeadlineExceeded)
end
it 'returns nil' do
- expect(subject).to be(ALLOW)
+ expect(subject).to eq([ALLOW, attribs])
end
end
end