diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-05-08 00:10:34 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-05-08 00:10:34 +0300 |
commit | 96135034f442825a54cea2812192133d376fbc4b (patch) | |
tree | 3cef559fb406d6ea1dda2dc0d315c9e4c71c23d6 | |
parent | 1572e2a376c0315a0b910bb06e2f2d08cae43adb (diff) |
Add latest changes from gitlab-org/gitlab@master
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 |