diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 00000000000..1a8a8e8555e --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,89 @@ +image: registry.gitlab.com/gitlab-org/gitlab-development-kit/gitpod-workspace:gitpod-workspace-image + +tasks: + + - name: GDK + command: gp sync-await gdk-copied && cd /workspace/gitlab-development-kit && gdk help + + - init: | + echo "$(date) – Copying GDK" | tee -a /workspace/startup.log + mv $HOME/.rvm-workspace /workspace/.rvm + cp -r $HOME/gitlab-development-kit /workspace/ + ( + set -e + cd /workspace/gitlab-development-kit + [[ ! -L /workspace/gitlab-development-kit/gitlab ]] && ln -fs /workspace/gitlab /workspace/gitlab-development-kit/gitlab + # make webpack static, prevents that GitLab tries to connect to localhost webpack from browser outside the workspace + echo "webpack:" >> gdk.yml + echo " static: true" >> gdk.yml + # reconfigure GDK + echo "$(date) – Reconfiguring GDK" | tee -a /workspace/startup.log + gdk reconfigure + # run DB migrations + echo "$(date) – Running DB migrations" | tee -a /workspace/startup.log + make gitlab-db-migrate + cd - + # stop GDK + echo "$(date) – Stopping GDK" | tee -a /workspace/startup.log + gdk stop + echo "$(date) – GDK stopped" | tee -a /workspace/startup.log + ) + command: | + ( + set -e + gp sync-done gdk-copied + SECONDS=0 + cd /workspace/gitlab-development-kit + # update GDK + if [ "$GITLAB_UPDATE_GDK" == true ]; then + echo "$(date) – Updating GDK" | tee -a /workspace/startup.log + gdk update + fi + # start GDK + echo "$(date) – Starting GDK" | tee -a /workspace/startup.log + export RAILS_HOSTS=$(gp url 3000 | sed -e 's+^http[s]*://++') + gdk start + # Run DB migrations + if [ "$GITLAB_RUN_DB_MIGRATIONS" == true ]; then + make gitlab-db-migrate + fi + # Fix DB key + if [ "$GITLAB_FIX_DB_KEY" = true ]; then + echo "$(date) – Fixing DB key" | tee -a /workspace/startup.log + cd gitlab + # see https://gitlab.com/gitlab-org/gitlab-foss/-/issues/56403#note_132515069 + printf 'ApplicationSetting.last.update_column(:runners_registration_token_encrypted, nil)\nexit\n' | bundle exec rails c + cd - + fi + # Waiting for GitLab ... + gp await-port 3000 + printf "Waiting for GitLab at $(gp url 3000) ..." + until $(curl -sNL $(gp url 3000) | grep -q "GitLab"); do printf '.'; sleep 5; done && echo "" + # Give Gitpod a few more seconds to set up everything ... + sleep 5 + printf "$(date) – GitLab is up (took ~%.1f minutes)\n" "$((10*$SECONDS/60))e-1" | tee -a /workspace/startup.log + gp preview $(gp url 3000) || true + ) + +ports: + - port: 3000 # rails-web + onOpen: ignore + - port: 3010 # gitlab-pages + onOpen: ignore + - port: 3808 # webpack + onOpen: ignore + - port: 5000 # auto_devops + onOpen: ignore + - port: 5778 # jaeger + onOpen: ignore + - port: 9000 # object_store / minio + onOpen: ignore + +vscode: + extensions: + - rebornix.ruby@0.27.0:QyGBeRyslOfdRgOPRGm6PQ== + - wingrunr21.vscode-ruby@0.27.0:beIqQUhLRuJ5Vao4B2Lyng== + - karunamurti.haml@1.1.0:twCwOYt3/Ttfb3+iwblPDA== + - octref.vetur@0.25.0:UofirBhedyhdx/jCnPeJDg== + - dbaeumer.vscode-eslint@2.1.3:1NRvj3UKNTNwmYjptmUmIw== + - GitLab.gitlab-workflow@3.3.0:50q1byIi4M01G9qrTCCAYQ== diff --git a/.theia/settings.json b/.theia/settings.json new file mode 100644 index 00000000000..8ad32b373f4 --- /dev/null +++ b/.theia/settings.json @@ -0,0 +1,11 @@ +{ + "ruby.codeCompletion": "rcodetools", + "ruby.format": "standard", + "ruby.intellisense": "rubyLocate", + "ruby.useBundler": true, + "ruby.useLanguageServer": true, + "ruby.lint": { + "rubocop": true, + "useBundler": true + } +} diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 593939ab55c..98a6164e5af 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -05cd75fb57f06f29978e6cc0da3f7bc35d85859f +3bdd23173595a931aac476ad0c07c702c30f4391 diff --git a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js index d712c90242c..83f2ca0bdc2 100644 --- a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js +++ b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js @@ -14,7 +14,6 @@ export default function initGFMInput($els) { milestones: enableGFM, mergeRequests: enableGFM, labels: enableGFM, - vulnerabilities: enableGFM, }); }); } diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 4c92aa41c41..62948f74aaa 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -4,7 +4,6 @@ import { escape, template } from 'lodash'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import glRegexp from './lib/utils/regexp'; import AjaxCache from './lib/utils/ajax_cache'; -import axios from '~/lib/utils/axios_utils'; import { spriteIcon } from './lib/utils/common_utils'; import * as Emoji from '~/emoji'; @@ -53,7 +52,6 @@ export const defaultAutocompleteConfig = { milestones: true, labels: true, snippets: true, - vulnerabilities: true, }; class GfmAutoComplete { @@ -61,7 +59,6 @@ class GfmAutoComplete { this.dataSources = dataSources; this.cachedData = {}; this.isLoadingData = {}; - this.previousQuery = ''; } setup(input, enableMap = defaultAutocompleteConfig) { @@ -557,7 +554,7 @@ class GfmAutoComplete { } getDefaultCallbacks() { - const self = this; + const fetchData = this.fetchData.bind(this); return { sorter(query, items, searchKey) { @@ -570,15 +567,7 @@ class GfmAutoComplete { }, filter(query, data, searchKey) { if (GfmAutoComplete.isLoading(data)) { - self.fetchData(this.$inputor, this.at); - return data; - } - if ( - GfmAutoComplete.typesWithBackendFiltering.includes(GfmAutoComplete.atTypeMap[this.at]) && - self.previousQuery !== query - ) { - self.fetchData(this.$inputor, this.at, query); - self.previousQuery = query; + fetchData(this.$inputor, this.at); return data; } return $.fn.atwho.default.callbacks.filter(query, data, searchKey); @@ -626,22 +615,13 @@ class GfmAutoComplete { }; } - fetchData($input, at, search) { + fetchData($input, at) { if (this.isLoadingData[at]) return; this.isLoadingData[at] = true; const dataSource = this.dataSources[GfmAutoComplete.atTypeMap[at]]; - if (GfmAutoComplete.typesWithBackendFiltering.includes(GfmAutoComplete.atTypeMap[at])) { - axios - .get(dataSource, { params: { search } }) - .then(({ data }) => { - this.loadData($input, at, data); - }) - .catch(() => { - this.isLoadingData[at] = false; - }); - } else if (this.cachedData[at]) { + if (this.cachedData[at]) { this.loadData($input, at, this.cachedData[at]); } else if (GfmAutoComplete.atTypeMap[at] === 'emojis') { this.loadEmojiData($input, at).catch(() => {}); @@ -727,8 +707,7 @@ class GfmAutoComplete { // https://github.com/ichord/At.js const atSymbolsWithBar = Object.keys(controllers) .join('|') - .replace(/[$]/, '\\$&') - .replace(/[+]/, '\\+'); + .replace(/[$]/, '\\$&'); const atSymbolsWithoutBar = Object.keys(controllers).join(''); const targetSubtext = subtext.split(GfmAutoComplete.regexSubtext).pop(); const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'); @@ -759,12 +738,9 @@ GfmAutoComplete.atTypeMap = { '~': 'labels', '%': 'milestones', '/': 'commands', - '+': 'vulnerabilities', $: 'snippets', }; -GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities']; - function findEmoji(name) { return Emoji.searchEmoji(name, { match: 'contains', raw: true }).sort((a, b) => { if (a.index !== b.index) { diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index 245d71ce55f..e1f9d858f2b 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -310,7 +310,7 @@ export default { category="primary" variant="success" :href="newIncidentPath" - @click="redirecting = true" + @click="navigateToCreateNewIncident" > {{ $options.i18n.createIncidentBtnLabel }} diff --git a/app/assets/javascripts/incidents/constants.js b/app/assets/javascripts/incidents/constants.js index 9c31a5702a2..b82980b5628 100644 --- a/app/assets/javascripts/incidents/constants.js +++ b/app/assets/javascripts/incidents/constants.js @@ -34,6 +34,13 @@ export const INCIDENT_STATUS_TABS = [ }, ]; +export const DEFAULT_PAGE_SIZE = 20; +export const TH_CREATED_AT_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' }; +export const TH_SEVERITY_TEST_ID = { 'data-testid': 'incident-management-severity-sort' }; +export const TH_INCIDENT_SLA_TEST_ID = { 'data-testid': 'incident-management-sla' }; +export const TH_PUBLISHED_TEST_ID = { 'data-testid': 'incident-management-published-sort' }; +export const INCIDENT_DETAILS_PATH = 'incident'; + /** * Tracks snowplow event when user clicks create new incident */ @@ -43,16 +50,17 @@ export const trackIncidentCreateNewOptions = { }; /** - * Tracks snowplow event when user views incident list + * Tracks snowplow event when user views incidents list */ export const trackIncidentListViewsOptions = { category: 'Incident Management', action: 'view_incidents_list', }; -export const DEFAULT_PAGE_SIZE = 20; -export const TH_CREATED_AT_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' }; -export const TH_SEVERITY_TEST_ID = { 'data-testid': 'incident-management-severity-sort' }; -export const TH_INCIDENT_SLA_TEST_ID = { 'data-testid': 'incident-management-sla' }; -export const TH_PUBLISHED_TEST_ID = { 'data-testid': 'incident-management-published-sort' }; -export const INCIDENT_DETAILS_PATH = 'incident'; +/** + * Tracks snowplow event when user views incident details + */ +export const trackIncidentDetailsViewsOptions = { + category: 'Incident Management', + action: 'view_incident_details', +}; diff --git a/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue index 19a9c67553a..c593fa33973 100644 --- a/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue +++ b/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue @@ -5,8 +5,10 @@ import HighlightBar from './highlight_bar.vue'; import createFlash from '~/flash'; import { s__ } from '~/locale'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; +import Tracking from '~/tracking'; import getAlert from './graphql/queries/get_alert.graphql'; +import { trackIncidentDetailsViewsOptions } from '~/incidents/constants'; export default { components: { @@ -46,6 +48,15 @@ export default { return this.$apollo.queries.alert.loading; }, }, + mounted() { + this.trackPageViews(); + }, + methods: { + trackPageViews() { + const { category, action } = trackIncidentDetailsViewsOptions; + Tracking.event(category, action); + }, + }, }; diff --git a/app/assets/javascripts/releases/mount_show.js b/app/assets/javascripts/releases/mount_show.js index eef015ee0a6..b09ecc9fb55 100644 --- a/app/assets/javascripts/releases/mount_show.js +++ b/app/assets/javascripts/releases/mount_show.js @@ -13,6 +13,9 @@ export default () => { modules: { detail: createDetailModule(el.dataset), }, + featureFlags: { + graphqlIndividualReleasePage: Boolean(gon.features?.graphqlIndividualReleasePage), + }, }); return new Vue({ diff --git a/app/assets/javascripts/releases/stores/modules/detail/actions.js b/app/assets/javascripts/releases/stores/modules/detail/actions.js index 4b1e4fdaad6..e8a46f40d20 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/actions.js +++ b/app/assets/javascripts/releases/stores/modules/detail/actions.js @@ -3,7 +3,13 @@ import api from '~/api'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__ } from '~/locale'; import { redirectTo } from '~/lib/utils/url_utility'; -import { releaseToApiJson, apiJsonToRelease } from '~/releases/util'; +import { + releaseToApiJson, + apiJsonToRelease, + gqClient, + convertOneReleaseGraphQLResponse, +} from '~/releases/util'; +import oneReleaseQuery from '~/releases/queries/one_release.query.graphql'; export const initializeRelease = ({ commit, dispatch, getters }) => { if (getters.isExistingRelease) { @@ -18,9 +24,29 @@ export const initializeRelease = ({ commit, dispatch, getters }) => { return Promise.resolve(); }; -export const fetchRelease = ({ commit, state }) => { +export const fetchRelease = ({ commit, state, rootState }) => { commit(types.REQUEST_RELEASE); + if (rootState.featureFlags?.graphqlIndividualReleasePage) { + return gqClient + .query({ + query: oneReleaseQuery, + variables: { + fullPath: state.projectPath, + tagName: state.tagName, + }, + }) + .then(response => { + const { data: release } = convertOneReleaseGraphQLResponse(response); + + commit(types.RECEIVE_RELEASE_SUCCESS, release); + }) + .catch(error => { + commit(types.RECEIVE_RELEASE_ERROR, error); + createFlash(s__('Release|Something went wrong while getting the release details')); + }); + } + return api .release(state.projectId, state.tagName) .then(({ data }) => { diff --git a/app/assets/javascripts/releases/stores/modules/detail/state.js b/app/assets/javascripts/releases/stores/modules/detail/state.js index a46e750df53..782a5c46d6c 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/state.js +++ b/app/assets/javascripts/releases/stores/modules/detail/state.js @@ -1,5 +1,6 @@ export default ({ projectId, + projectPath, markdownDocsPath, markdownPreviewPath, updateReleaseApiDocsPath, @@ -12,6 +13,7 @@ export default ({ defaultBranch = null, }) => ({ projectId, + projectPath, markdownDocsPath, markdownPreviewPath, updateReleaseApiDocsPath, diff --git a/app/assets/javascripts/shared/milestones/form.js b/app/assets/javascripts/shared/milestones/form.js index 9ee02f923d5..0ff84dc4667 100644 --- a/app/assets/javascripts/shared/milestones/form.js +++ b/app/assets/javascripts/shared/milestones/form.js @@ -16,6 +16,5 @@ export default (initGFM = true) => { milestones: initGFM, labels: initGFM, snippets: initGFM, - vulnerabilities: initGFM, }); }; diff --git a/app/assets/javascripts/static_site_editor/pages/success.vue b/app/assets/javascripts/static_site_editor/pages/success.vue index 5b013c27c35..1ee1a3b0edf 100644 --- a/app/assets/javascripts/static_site_editor/pages/success.vue +++ b/app/assets/javascripts/static_site_editor/pages/success.vue @@ -61,45 +61,46 @@ export default { }; diff --git a/app/assets/javascripts/static_site_editor/services/templater.js b/app/assets/javascripts/static_site_editor/services/templater.js index 318f2099064..d302aea78a3 100644 --- a/app/assets/javascripts/static_site_editor/services/templater.js +++ b/app/assets/javascripts/static_site_editor/services/templater.js @@ -15,7 +15,7 @@ const markPrefix = `${marker}-${Date.now()}`; const reHelpers = { template: `.| |\\t|\\n(?!(\\n|${markPrefix}))`, - openTag: '<(?!iframe)[a-zA-Z]+.*?>', + openTag: '<(?!figure|iframe)[a-zA-Z]+.*?>', closeTag: '', }; const reTemplated = new RegExp(`(^${wrapPrefix}(${reHelpers.template})+?${wrapPostfix}$)`, 'gm'); diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 3b0f9b2867c..65116ed8ca3 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -178,7 +178,6 @@ export default { milestones: this.enableAutocomplete, labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, snippets: this.enableAutocomplete, - vulnerabilities: this.enableAutocomplete, }, true, ); diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js index 44d43ca8f69..cbb30baa488 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js @@ -2,9 +2,14 @@ import { __ } from '~/locale'; export const CUSTOM_EVENTS = { openAddImageModal: 'gl_openAddImageModal', + openInsertVideoModal: 'gl_openInsertVideoModal', }; -export const ALLOWED_VIDEO_ORIGINS = ['https://www.youtube.com']; +export const YOUTUBE_URL = 'https://www.youtube.com'; + +export const YOUTUBE_EMBED_URL = `${YOUTUBE_URL}/embed`; + +export const ALLOWED_VIDEO_ORIGINS = [YOUTUBE_URL]; /* eslint-disable @gitlab/require-i18n-strings */ export const TOOLBAR_ITEM_CONFIGS = [ @@ -25,6 +30,7 @@ export const TOOLBAR_ITEM_CONFIGS = [ { icon: 'dash', command: 'HR', tooltip: __('Add a line') }, { icon: 'table', event: 'openPopupAddTable', classes: 'tui-table', tooltip: __('Add a table') }, { icon: 'doc-image', event: CUSTOM_EVENTS.openAddImageModal, tooltip: __('Insert an image') }, + { icon: 'live-preview', event: CUSTOM_EVENTS.openInsertVideoModal, tooltip: __('Insert video') }, { isDivider: true }, { icon: 'code', command: 'Code', tooltip: __('Insert inline code') }, { icon: 'doc-code', command: 'CodeBlock', tooltip: __('Insert a code block') }, @@ -42,3 +48,10 @@ export const EDITOR_PREVIEW_STYLE = 'horizontal'; export const IMAGE_TABS = { UPLOAD_TAB: 0, URL_TAB: 1 }; export const MAX_FILE_SIZE = 2097152; // 2Mb + +export const VIDEO_ATTRIBUTES = { + width: '560', + height: '315', + frameBorder: '0', + allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture', +}; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue new file mode 100644 index 00000000000..99bb2080610 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue @@ -0,0 +1,91 @@ + + diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue index d96fe46522e..c2518441506 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue @@ -3,6 +3,7 @@ import 'codemirror/lib/codemirror.css'; import '@toast-ui/editor/dist/toastui-editor.css'; import AddImageModal from './modals/add_image/add_image_modal.vue'; +import InsertVideoModal from './modals/insert_video_modal.vue'; import { EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE, CUSTOM_EVENTS } from './constants'; import { @@ -12,6 +13,7 @@ import { removeCustomEventListener, addImage, getMarkdown, + insertVideo, } from './services/editor_service'; export default { @@ -21,6 +23,7 @@ export default { toast => toast.Editor, ), AddImageModal, + InsertVideoModal, }, props: { content: { @@ -63,6 +66,12 @@ export default { editorInstance() { return this.$refs.editor; }, + customEventListeners() { + return [ + { event: CUSTOM_EVENTS.openAddImageModal, listener: this.onOpenAddImageModal }, + { event: CUSTOM_EVENTS.openInsertVideoModal, listener: this.onOpenInsertVideoModal }, + ]; + }, }, created() { this.editorOptions = getEditorOptions(this.options); @@ -72,16 +81,16 @@ export default { }, methods: { addListeners(editorApi) { - addCustomEventListener(editorApi, CUSTOM_EVENTS.openAddImageModal, this.onOpenAddImageModal); + this.customEventListeners.forEach(({ event, listener }) => { + addCustomEventListener(editorApi, event, listener); + }); editorApi.eventManager.listen('changeMode', this.onChangeMode); }, removeListeners() { - removeCustomEventListener( - this.editorApi, - CUSTOM_EVENTS.openAddImageModal, - this.onOpenAddImageModal, - ); + this.customEventListeners.forEach(({ event, listener }) => { + removeCustomEventListener(this.editorApi, event, listener); + }); this.editorApi.eventManager.removeEventHandler('changeMode', this.onChangeMode); }, @@ -111,6 +120,12 @@ export default { addImage(this.editorInstance, image); }, + onOpenInsertVideoModal() { + this.$refs.insertVideoModal.show(); + }, + onInsertVideo(url) { + insertVideo(this.editorInstance, url); + }, onChangeMode(newMode) { this.$emit('modeChange', newMode); }, @@ -130,5 +145,6 @@ export default { @load="onLoad" /> + diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js index bbe3825138c..8b3fbcabcfa 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js @@ -3,7 +3,7 @@ import { defaults } from 'lodash'; import ToolbarItem from '../toolbar_item.vue'; import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer'; import buildCustomHTMLRenderer from './build_custom_renderer'; -import { TOOLBAR_ITEM_CONFIGS } from '../constants'; +import { TOOLBAR_ITEM_CONFIGS, VIDEO_ATTRIBUTES } from '../constants'; import sanitizeHTML from './sanitize_html'; const buildWrapper = propsData => { @@ -17,6 +17,23 @@ const buildWrapper = propsData => { return instance.$el; }; +const buildVideoIframe = src => { + const wrapper = document.createElement('figure'); + const iframe = document.createElement('iframe'); + const videoAttributes = { ...VIDEO_ATTRIBUTES, src }; + const wrapperClasses = ['gl-relative', 'gl-h-0', 'video_container']; + const iframeClasses = ['gl-absolute', 'gl-top-0', 'gl-left-0', 'gl-w-full', 'gl-h-full']; + + wrapper.setAttribute('contenteditable', 'false'); + wrapper.classList.add(...wrapperClasses); + iframe.classList.add(...iframeClasses); + Object.assign(iframe, videoAttributes); + + wrapper.appendChild(iframe); + + return wrapper; +}; + export const generateToolbarItem = config => { const { icon, classes, event, command, tooltip, isDivider } = config; @@ -44,6 +61,16 @@ export const removeCustomEventListener = (editorApi, event, handler) => export const addImage = ({ editor }, image) => editor.exec('AddImage', image); +export const insertVideo = ({ editor }, url) => { + const videoIframe = buildVideoIframe(url); + + if (editor.isWysiwygMode()) { + editor.getSquire().insertElement(videoIframe); + } else { + editor.insertText(videoIframe.outerHTML); + } +}; + export const getMarkdown = editorInstance => editorInstance.invoke('getMarkdown'); /** diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index d49134eb648..c912f9bfd3c 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -11,8 +11,6 @@ @import './pages/editor'; @import './pages/environment_logs'; @import './pages/events'; -@import './pages/experience_level'; -@import './pages/experimental_separate_sign_up'; @import './pages/groups'; @import './pages/help'; @import './pages/import'; diff --git a/app/assets/stylesheets/components/rich_content_editor.scss b/app/assets/stylesheets/components/rich_content_editor.scss index b1189137d59..d97a9bc227d 100644 --- a/app/assets/stylesheets/components/rich_content_editor.scss +++ b/app/assets/stylesheets/components/rich_content_editor.scss @@ -44,3 +44,11 @@ @include gl-line-height-20; } } + +/** +* Styling below ensures that YouTube videos are displayed in the editor the same as they would in about.gitlab.com +* https://gitlab.com/gitlab-com/www-gitlab-com/-/blob/master/source/stylesheets/_base.scss#L977 +*/ +.video_container { + padding-bottom: 56.25%; +} diff --git a/app/assets/stylesheets/page_bundles/experimental_separate_sign_up.scss b/app/assets/stylesheets/page_bundles/experimental_separate_sign_up.scss new file mode 100644 index 00000000000..337b5b001fe --- /dev/null +++ b/app/assets/stylesheets/page_bundles/experimental_separate_sign_up.scss @@ -0,0 +1,96 @@ +@import 'mixins_and_variables_and_functions'; + +.signup-page { + .page-wrap { + background-color: var(--gray-10, $gray-10); + } + + .signup-box-container { + max-width: 960px; + } + + .signup-box { + background-color: var(--white, $white); + box-shadow: 0 0 0 1px var(--border-color, $border-color); + border-radius: $border-radius; + } + + .form-control { + &:active, + &:focus { + background-color: var(--white, $white); + } + } + + .devise-errors { + h2 { + font-size: $gl-font-size; + color: var(--red-700, $red-700); + } + } + + .omniauth-divider { + &::before, + &::after { + content: ''; + flex: 1; + border-bottom: 1px solid var(--gray-100, $gray-100); + margin: $gl-padding-24 0; + } + + &::before { + margin-right: $gl-padding; + } + + &::after { + margin-left: $gl-padding; + } + } + + .omniauth-btn { + width: 48%; + + @include media-breakpoint-down(md) { + width: 100%; + } + + img { + width: $default-icon-size; + height: $default-icon-size; + } + } + + .decline-page { + width: 350px; + } +} + +.signup-page[data-page^='registrations:experience_levels'] { + $card-shadow-color: rgba(var(--black, $black), 0.2); + + .page-wrap { + background-color: var(--white, $white); + } + + .card-deck { + max-width: 828px; + } + + .card { + transition: box-shadow 0.3s ease-in-out; + } + + .card:hover { + box-shadow: 0 $gl-spacing-scale-3 $gl-spacing-scale-5 $card-shadow-color; + } + + @media (min-width: $breakpoint-sm) { + .card-deck .card { + margin: 0 $gl-spacing-scale-3; + } + } + + .stretched-link:hover { + text-decoration: none; + } +} diff --git a/app/assets/stylesheets/pages/experience_level.scss b/app/assets/stylesheets/pages/experience_level.scss deleted file mode 100644 index e57ad6321a5..00000000000 --- a/app/assets/stylesheets/pages/experience_level.scss +++ /dev/null @@ -1,29 +0,0 @@ -.signup-page[data-page^='registrations:experience_levels'] { - $card-shadow-color: rgba($black, 0.2); - - .page-wrap { - background-color: $white; - } - - .card-deck { - max-width: 828px; - } - - .card { - transition: box-shadow 0.3s ease-in-out; - } - - .card:hover { - box-shadow: 0 $gl-spacing-scale-3 $gl-spacing-scale-5 $card-shadow-color; - } - - @media (min-width: $breakpoint-sm) { - .card-deck .card { - margin: 0 $gl-spacing-scale-3; - } - } - - .stretched-link:hover { - text-decoration: none; - } -} diff --git a/app/assets/stylesheets/pages/experimental_separate_sign_up.scss b/app/assets/stylesheets/pages/experimental_separate_sign_up.scss deleted file mode 100644 index 415ff01bc33..00000000000 --- a/app/assets/stylesheets/pages/experimental_separate_sign_up.scss +++ /dev/null @@ -1,64 +0,0 @@ -.signup-page { - .page-wrap { - background-color: $gray-light; - } - - .signup-box-container { - max-width: 960px; - } - - .signup-box { - background-color: $white; - box-shadow: 0 0 0 1px $border-color; - border-radius: $border-radius; - } - - .form-control { - &:active, - &:focus { - background-color: $white; - } - } - - .devise-errors { - h2 { - font-size: $gl-font-size; - color: $red-700; - } - } - - .omniauth-divider { - &::before, - &::after { - content: ''; - flex: 1; - border-bottom: 1px solid $gray-dark; - margin: $gl-padding-24 0; - } - - &::before { - margin-right: $gl-padding; - } - - &::after { - margin-left: $gl-padding; - } - } - - .omniauth-btn { - width: 48%; - - @include media-breakpoint-down(md) { - width: 100%; - } - - img { - width: $default-icon-size; - height: $default-icon-size; - } - } - - .decline-page { - width: 350px; - } -} diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 0d7af57328a..3f5f3b6e9df 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -150,7 +150,7 @@ module IssuableCollections common_attributes + [:project, project: :namespace] when 'MergeRequest' common_attributes + [ - :target_project, :latest_merge_request_diff, :approvals, :approved_by_users, + :target_project, :latest_merge_request_diff, :approvals, :approved_by_users, :reviewers, source_project: :route, head_pipeline: :project, target_project: :namespace ] end diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index d521267c50c..816a93f14c6 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -105,7 +105,7 @@ module MembershipActions # rubocop: enable CodeReuse/ActiveRecord def resend_invite - member = membershipable.members.find(params[:id]) + member = membershipable_members.find(params[:id]) if member.invite? member.resend_invite @@ -122,6 +122,10 @@ module MembershipActions raise NotImplementedError end + def membershipable_members + raise NotImplementedError + end + def root_params_key case membershipable when Namespace diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 7cf07afd63b..5df7ff0632a 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -71,6 +71,10 @@ class Groups::GroupMembersController < Groups::ApplicationController def filter_params params.permit(:two_factor, :search).merge(sort: @sort) end + + def membershipable_members + group.members + end end Groups::GroupMembersController.prepend_if_ee('EE::Groups::GroupMembersController') diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb index 001967b8bb4..e9c533daa80 100644 --- a/app/controllers/projects/autocomplete_sources_controller.rb +++ b/app/controllers/projects/autocomplete_sources_controller.rb @@ -39,7 +39,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController private def autocomplete_service - @autocomplete_service ||= ::Projects::AutocompleteService.new(@project, current_user, params) + @autocomplete_service ||= ::Projects::AutocompleteService.new(@project, current_user) end def target diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 954b55a821a..39dd7a9899d 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -5,11 +5,11 @@ class Projects::PipelinesController < Projects::ApplicationController include Analytics::UniqueVisitsHelper before_action :whitelist_query_limiting, only: [:create, :retry] - before_action :pipeline, except: [:index, :new, :create, :charts] + before_action :pipeline, except: [:index, :new, :create, :charts, :config_variables] before_action :set_pipeline_path, only: [:show] before_action :authorize_read_pipeline! before_action :authorize_read_build!, only: [:index] - before_action :authorize_create_pipeline!, only: [:new, :create] + before_action :authorize_create_pipeline!, only: [:new, :create, :config_variables] before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action do push_frontend_feature_flag(:filter_pipelines_search, project, default_enabled: true) @@ -209,6 +209,14 @@ class Projects::PipelinesController < Projects::ApplicationController end end + def config_variables + respond_to do |format| + format.json do + render json: Ci::ListConfigVariablesService.new(@project).execute(params[:sha]) + end + end + end + private def serialize_pipelines diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 19d12e77217..631f627838b 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -57,6 +57,10 @@ class Projects::ProjectMembersController < Projects::ApplicationController def filter_params params.permit(:search).merge(sort: @sort) end + + def membershipable_members + project.members + end end Projects::ProjectMembersController.prepend_if_ee('EE::Projects::ProjectMembersController') diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index b8c6d1d6c9e..ac2a8e5405d 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -9,6 +9,7 @@ class Projects::ReleasesController < Projects::ApplicationController push_frontend_feature_flag(:graphql_release_data, project, default_enabled: true) push_frontend_feature_flag(:graphql_milestone_stats, project, default_enabled: true) push_frontend_feature_flag(:graphql_releases_page, project, default_enabled: true) + push_frontend_feature_flag(:graphql_individual_release_page, project) end before_action :authorize_update_release!, only: %i[edit update] before_action :authorize_create_release!, only: :new diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 7c650f5cb22..f35ff6e4992 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -397,7 +397,7 @@ class IssuableFinder elsif params.filter_by_any_assignee? items.assigned elsif params.assignee - items.assigned_to(params.assignee) + items_assigned_to(items, params.assignee) elsif params.assignee_id? || params.assignee_username? # assignee not found items.none else @@ -405,6 +405,10 @@ class IssuableFinder end end + def items_assigned_to(items, user) + items.assigned_to(user) + end + def by_negated_assignee(items) # We want CE users to be able to say "Issues not assigned to either PersonA nor PersonB" if not_params.assignees.present? diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index 7bdc98d2e3d..11ce6409ebf 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -145,6 +145,10 @@ class MergeRequestsFinder < IssuableFinder .execute(items) end # rubocop: enable CodeReuse/Finder + + def items_assigned_to(items, user) + MergeRequest.from_union([super, items.reviewer_assigned_to(user)]) + end end MergeRequestsFinder.prepend_if_ee('EE::MergeRequestsFinder') diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 871d19c6a8c..c02adfcf4c6 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -159,7 +159,6 @@ module NotesHelper members: autocomplete, issues: autocomplete, mergeRequests: autocomplete, - vulnerabilities: autocomplete, epics: autocomplete, milestones: autocomplete, labels: autocomplete diff --git a/app/helpers/releases_helper.rb b/app/helpers/releases_helper.rb index 979a68ecb7b..050b27840a0 100644 --- a/app/helpers/releases_helper.rb +++ b/app/helpers/releases_helper.rb @@ -29,6 +29,14 @@ module ReleasesHelper end end + def data_for_show_page + { + project_id: @project.id, + project_path: @project.full_path, + tag_name: @release.tag + } + end + def data_for_edit_release_page new_edit_pages_shared_data.merge( tag_name: @release.tag, @@ -48,6 +56,7 @@ module ReleasesHelper def new_edit_pages_shared_data { project_id: @project.id, + project_path: @project.full_path, markdown_preview_path: preview_markdown_path(@project), markdown_docs_path: help_page_path('user/markdown'), update_release_api_docs_path: help_page_path('api/releases/index.md', anchor: 'update-a-release'), diff --git a/app/models/clusters/applications/fluentd.rb b/app/models/clusters/applications/fluentd.rb index 3fd6e870edc..c608d37be77 100644 --- a/app/models/clusters/applications/fluentd.rb +++ b/app/models/clusters/applications/fluentd.rb @@ -22,7 +22,11 @@ module Clusters validate :has_at_least_one_log_enabled? def chart - 'stable/fluentd' + 'fluentd/fluentd' + end + + def repository + 'https://gitlab-org.gitlab.io/cluster-integration/helm-stable-archive' end def install_command diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index 1d08f38a2f1..d5412714858 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -46,7 +46,11 @@ module Clusters end def chart - 'stable/nginx-ingress' + "#{name}/nginx-ingress" + end + + def repository + 'https://gitlab-org.gitlab.io/cluster-integration/helm-stable-archive' end def values @@ -60,6 +64,7 @@ module Clusters def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, + repository: repository, version: VERSION, rbac: cluster.platform_kubernetes_rbac?, chart: chart, diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index dd6a4144608..7679296699f 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -51,7 +51,11 @@ module Clusters end def chart - 'stable/prometheus' + "#{name}/prometheus" + end + + def repository + 'https://gitlab-org.gitlab.io/cluster-integration/helm-stable-archive' end def service_name @@ -65,6 +69,7 @@ module Clusters def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, + repository: repository, version: VERSION, rbac: cluster.platform_kubernetes_rbac?, chart: chart, @@ -76,6 +81,7 @@ module Clusters def patch_command(values) ::Gitlab::Kubernetes::Helm::PatchCommand.new( name: name, + repository: repository, version: version, rbac: cluster.platform_kubernetes_rbac?, chart: chart, diff --git a/app/models/concerns/mentionable/reference_regexes.rb b/app/models/concerns/mentionable/reference_regexes.rb index 5a5ce1809d0..307d58a3a3c 100644 --- a/app/models/concerns/mentionable/reference_regexes.rb +++ b/app/models/concerns/mentionable/reference_regexes.rb @@ -22,7 +22,7 @@ module Mentionable def self.default_pattern strong_memoize(:default_pattern) do issue_pattern = Issue.reference_pattern - link_patterns = Regexp.union([Issue, Commit, MergeRequest, Epic, Vulnerability].map(&:link_reference_pattern).compact) + link_patterns = Regexp.union([Issue, Commit, MergeRequest, Epic].map(&:link_reference_pattern).compact) reference_pattern(link_patterns, issue_pattern) end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index ed53676ea97..0992e240bf5 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -302,6 +302,10 @@ class MergeRequest < ApplicationRecord includes(:metrics) end + scope :reviewer_assigned_to, ->(user) do + where("EXISTS (SELECT TRUE FROM merge_request_reviewers WHERE user_id = ? AND merge_request_id = merge_requests.id)", user.id) + end + after_save :keep_around_commit, unless: :importing? alias_attribute :project, :target_project diff --git a/app/models/project.rb b/app/models/project.rb index d7f5254a6e3..5b1cdf0a5b4 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1340,7 +1340,8 @@ class Project < ApplicationRecord end def find_or_initialize_services - available_services_names = Service.available_services_names - disabled_services + available_services_names = + Service.available_services_names + Service.project_specific_services_names - disabled_services available_services_names.map do |service_name| find_or_initialize_service(service_name) @@ -2514,6 +2515,10 @@ class Project < ApplicationRecord ci_config_path.presence || Ci::Pipeline::DEFAULT_CONFIG_PATH end + def ci_config_for(sha) + repository.gitlab_ci_yml_for(sha, ci_config_path_or_default) + end + def enabled_group_deploy_keys return GroupDeployKey.none unless group diff --git a/app/models/service.rb b/app/models/service.rb index ced9abb9600..764f417362f 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -208,6 +208,10 @@ class Service < ApplicationRecord DEV_SERVICE_NAMES end + def self.project_specific_services_names + [] + end + def self.available_services_types available_services_names.map { |service_name| "#{service_name}_service".camelize } end diff --git a/app/models/vulnerability.rb b/app/models/vulnerability.rb index 0de3d73ca9d..a4338c4e2bd 100644 --- a/app/models/vulnerability.rb +++ b/app/models/vulnerability.rb @@ -1,21 +1,7 @@ # frozen_string_literal: true # Placeholder class for model that is implemented in EE -# It reserves '+' as a reference prefix, but the table does not exist in FOSS class Vulnerability < ApplicationRecord - include IgnorableColumns - - def self.link_reference_pattern - nil - end - - def self.reference_prefix - '+' - end - - def self.reference_prefix_escaped - '+' - end end Vulnerability.prepend_if_ee('EE::Vulnerability') diff --git a/app/services/ci/list_config_variables_service.rb b/app/services/ci/list_config_variables_service.rb new file mode 100644 index 00000000000..b5dc192b512 --- /dev/null +++ b/app/services/ci/list_config_variables_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Ci + class ListConfigVariablesService < ::BaseService + def execute(sha) + config = project.ci_config_for(sha) + return {} unless config + + result = Gitlab::Ci::YamlProcessor.new(config).execute + result.valid? ? result.variables_with_data : {} + end + end +end diff --git a/app/views/layouts/devise_experimental_onboarding_issues.html.haml b/app/views/layouts/devise_experimental_onboarding_issues.html.haml index df2afbe60ae..ec9867f9e1f 100644 --- a/app/views/layouts/devise_experimental_onboarding_issues.html.haml +++ b/app/views/layouts/devise_experimental_onboarding_issues.html.haml @@ -1,5 +1,6 @@ !!! 5 %html.devise-layout-html.navless{ class: system_message_class } + - add_page_specific_style 'page_bundles/experimental_separate_sign_up' = render "layouts/head" %body.ui-indigo.signup-page{ class: "#{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } } = render "layouts/header/logo_with_title" diff --git a/app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml b/app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml index fddfe14e05f..6be62645768 100644 --- a/app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml +++ b/app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml @@ -1,5 +1,6 @@ !!! 5 %html.devise-layout-html.navless{ class: system_message_class } + - add_page_specific_style 'page_bundles/experimental_separate_sign_up' = render "layouts/head" %body.ui-indigo.signup-page{ class: "#{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } } = render "layouts/header/logo_with_title" diff --git a/app/views/projects/releases/show.html.haml b/app/views/projects/releases/show.html.haml index 550a37dabcb..91ee9ad70a3 100644 --- a/app/views/projects/releases/show.html.haml +++ b/app/views/projects/releases/show.html.haml @@ -2,4 +2,4 @@ - page_title @release.name - page_description @release.description_html -#js-show-release-page{ data: { project_id: @project.id, tag_name: @release.tag } } +#js-show-release-page{ data: data_for_show_page } diff --git a/changelogs/unreleased/216642-embed-youtube-video.yml b/changelogs/unreleased/216642-embed-youtube-video.yml new file mode 100644 index 00000000000..cdfa02670eb --- /dev/null +++ b/changelogs/unreleased/216642-embed-youtube-video.yml @@ -0,0 +1,5 @@ +--- +title: Add the ability to insert a YouTube video +merge_request: 44102 +author: +type: added diff --git a/changelogs/unreleased/233722-snowplow-incidents-list-details-views.yml b/changelogs/unreleased/233722-snowplow-incidents-list-details-views.yml new file mode 100644 index 00000000000..24bab5312cc --- /dev/null +++ b/changelogs/unreleased/233722-snowplow-incidents-list-details-views.yml @@ -0,0 +1,5 @@ +--- +title: Snowplow tracking of Incident details views +merge_request: 45011 +author: +type: added diff --git a/changelogs/unreleased/262851-minimal-access-role-resending-member-invite-causes-404.yml b/changelogs/unreleased/262851-minimal-access-role-resending-member-invite-causes-404.yml new file mode 100644 index 00000000000..5d3c321c2d8 --- /dev/null +++ b/changelogs/unreleased/262851-minimal-access-role-resending-member-invite-causes-404.yml @@ -0,0 +1,5 @@ +--- +title: Allow re-sending invite to minimal access user +merge_request: 44936 +author: +type: fixed diff --git a/changelogs/unreleased/267505-mobile-empty-screen-svg-overlap.yml b/changelogs/unreleased/267505-mobile-empty-screen-svg-overlap.yml new file mode 100644 index 00000000000..2c07ee33e8b --- /dev/null +++ b/changelogs/unreleased/267505-mobile-empty-screen-svg-overlap.yml @@ -0,0 +1,5 @@ +--- +title: Class and markup cleanup to prevent SVG header bar overlap in Static Site Editor +merge_request: 45334 +author: +type: fixed diff --git a/changelogs/unreleased/use_mirror_of_helm_stable_repo.yml b/changelogs/unreleased/use_mirror_of_helm_stable_repo.yml new file mode 100644 index 00000000000..479ed48ce89 --- /dev/null +++ b/changelogs/unreleased/use_mirror_of_helm_stable_repo.yml @@ -0,0 +1,5 @@ +--- +title: "GitLab-managed apps: Use GitLab's repo as replacement for the Helm stable repo" +merge_request: 44875 +author: +type: other diff --git a/config/application.rb b/config/application.rb index 7bb70c13580..2d77f01b05c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -179,6 +179,7 @@ module Gitlab config.assets.precompile << "page_bundles/environments.css" config.assets.precompile << "page_bundles/error_tracking_details.css" config.assets.precompile << "page_bundles/error_tracking_index.css" + config.assets.precompile << "page_bundles/experimental_separate_sign_up.css" config.assets.precompile << "page_bundles/ide.css" config.assets.precompile << "page_bundles/issues_list.css" config.assets.precompile << "page_bundles/jira_connect.css" diff --git a/config/feature_flags/development/graphql_individual_release_page.yml b/config/feature_flags/development/graphql_individual_release_page.yml new file mode 100644 index 00000000000..e50d54e1853 --- /dev/null +++ b/config/feature_flags/development/graphql_individual_release_page.yml @@ -0,0 +1,7 @@ +--- +name: graphql_individual_release_page +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/44779 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/263522 +type: development +group: group::release management +default_enabled: false diff --git a/config/initializers/rails_host_authorization_gitpod.rb b/config/initializers/rails_host_authorization_gitpod.rb new file mode 100644 index 00000000000..0c1822bc91a --- /dev/null +++ b/config/initializers/rails_host_authorization_gitpod.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +if Rails.env.development? && ENV['GITPOD_WORKSPACE_ID'].present? + gitpod_host = URI(`gp url 3000`.strip).host + Rails.application.config.hosts += [gitpod_host] +end diff --git a/config/routes/pipelines.rb b/config/routes/pipelines.rb index 605e82af23a..0fc308b5e65 100644 --- a/config/routes/pipelines.rb +++ b/config/routes/pipelines.rb @@ -7,6 +7,7 @@ resources :pipelines, only: [:index, :new, :create, :show, :destroy] do scope '(*ref)', constraints: { ref: Gitlab::PathRegex.git_reference_regex } do get :latest, action: :show, defaults: { latest: true } end + get :config_variables end member do diff --git a/doc/user/markdown.md b/doc/user/markdown.md index 8cb20ed4829..e0612bad3b8 100644 --- a/doc/user/markdown.md +++ b/doc/user/markdown.md @@ -425,7 +425,6 @@ GFM recognizes the following: | merge request | `!123` | `namespace/project!123` | `project!123` | | snippet | `$123` | `namespace/project$123` | `project$123` | | epic **(ULTIMATE)** | `&123` | `group1/subgroup&123` | | -| vulnerability **(ULTIMATE)** | `+123` | `namespace/project+123` | `project+123` | | label by ID | `~123` | `namespace/project~123` | `project~123` | | one-word label by name | `~bug` | `namespace/project~bug` | `project~bug` | | multi-word label by name | `~"feature request"` | `namespace/project~"feature request"` | `project~"feature request"` | diff --git a/doc/user/permissions.md b/doc/user/permissions.md index abd02d6317f..bfa2d2af082 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -1,5 +1,7 @@ --- -description: 'Understand and explore the user permission levels in GitLab, and what features each of them grants you access to.' +stage: Manage +group: Access +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers --- # Permissions @@ -42,17 +44,17 @@ or an instance administrator, who receives all permissions. For more information The following table depicts the various user permission levels in a project. -| Action | Guest | Reporter | Developer |Maintainer| Owner* | +| Action | Guest | Reporter | Developer |Maintainer| Owner (*10*) | |---------------------------------------------------|---------|------------|-------------|----------|--------| | Download project | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | | Leave comments | ✓ | ✓ | ✓ | ✓ | ✓ | -| View allowed and denied licenses **(ULTIMATE)** | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | +| View allowed and denied licenses **(ULTIMATE)** | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | | View License Compliance reports **(ULTIMATE)** | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | | View Security reports **(ULTIMATE)** | ✓ (*3*) | ✓ | ✓ | ✓ | ✓ | | View Dependency list **(ULTIMATE)** | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | | View License list **(ULTIMATE)** | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | | View licenses in Dependency list **(ULTIMATE)** | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | -| View [Design Management](project/issues/design_management.md) pages | ✓ | ✓ | ✓ | ✓ | ✓ | +| View [Design Management](project/issues/design_management.md) pages | ✓ | ✓ | ✓ | ✓ | ✓ | | View project code | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | | Pull project code | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | | View GitLab Pages protected by [access control](project/pages/introduction.md#gitlab-pages-access-control) | ✓ | ✓ | ✓ | ✓ | ✓ | @@ -63,10 +65,14 @@ The following table depicts the various user permission levels in a project. | Create new issue | ✓ | ✓ | ✓ | ✓ | ✓ | | See related issues | ✓ | ✓ | ✓ | ✓ | ✓ | | Create confidential issue | ✓ | ✓ | ✓ | ✓ | ✓ | -| View confidential issues | (*2*) | ✓ | ✓ | ✓ | ✓ | | View [Releases](project/releases/index.md) | ✓ (*6*) | ✓ | ✓ | ✓ | ✓ | | View requirements **(ULTIMATE)** | ✓ | ✓ | ✓ | ✓ | ✓ | +| View Insights **(ULTIMATE)** | ✓ | ✓ | ✓ | ✓ | ✓ | +| View Issue analytics **(PREMIUM)** | ✓ | ✓ | ✓ | ✓ | ✓ | +| View Merge Request analytics **(STARTER)** | ✓ | ✓ | ✓ | ✓ | ✓ | +| View Value Stream analytics | ✓ | ✓ | ✓ | ✓ | ✓ | | Manage user-starred metrics dashboards (*7*) | ✓ | ✓ | ✓ | ✓ | ✓ | +| View confidential issues | (*2*) | ✓ | ✓ | ✓ | ✓ | | Assign issues | | ✓ | ✓ | ✓ | ✓ | | Label issues | | ✓ | ✓ | ✓ | ✓ | | Set issue weight | | ✓ | ✓ | ✓ | ✓ | @@ -79,14 +85,15 @@ The following table depicts the various user permission levels in a project. | See a container registry | | ✓ | ✓ | ✓ | ✓ | | See environments | | ✓ | ✓ | ✓ | ✓ | | See a list of merge requests | | ✓ | ✓ | ✓ | ✓ | -| View project statistics | | | ✓ | ✓ | ✓ | +| View CI/CD analytics | | ✓ | ✓ | ✓ | ✓ | +| View Code Review analytics **(STARTER)** | | ✓ | ✓ | ✓ | ✓ | +| View Repository analytics | | ✓ | ✓ | ✓ | ✓ | | View Error Tracking list | | ✓ | ✓ | ✓ | ✓ | | Create new merge request | | ✓ | ✓ | ✓ | ✓ | | View metrics dashboard annotations | | ✓ | ✓ | ✓ | ✓ | | Create/edit requirements **(ULTIMATE)** | | ✓ | ✓ | ✓ | ✓ | | Pull [packages](packages/index.md) | | ✓ | ✓ | ✓ | ✓ | | Publish [packages](packages/index.md) | | | ✓ | ✓ | ✓ | -| Delete [packages](packages/index.md) | | | | ✓ | ✓ | | Create/edit/delete a Cleanup policy | | | ✓ | ✓ | ✓ | | Upload [Design Management](project/issues/design_management.md) files | | | ✓ | ✓ | ✓ | | Create/edit/delete [Releases](project/releases/index.md)| | | ✓ | ✓ | ✓ | @@ -99,9 +106,12 @@ The following table depicts the various user permission levels in a project. | Lock merge request threads | | | ✓ | ✓ | ✓ | | Approve merge requests (*9*) | | | ✓ | ✓ | ✓ | | Manage/Accept merge requests | | | ✓ | ✓ | ✓ | +| View project statistics | | | ✓ | ✓ | ✓ | | Create new environments | | | ✓ | ✓ | ✓ | | Stop environments | | | ✓ | ✓ | ✓ | | Enable Review Apps | | | ✓ | ✓ | ✓ | +| View Pods logs | | | ✓ | ✓ | ✓ | +| Read Terraform state | | | ✓ | ✓ | ✓ | | Add tags | | | ✓ | ✓ | ✓ | | Cancel and retry jobs | | | ✓ | ✓ | ✓ | | Create or update commit status | | | ✓ (*5*) | ✓ | ✓ | @@ -123,6 +133,7 @@ The following table depicts the various user permission levels in a project. | Manage Feature Flags **(PREMIUM)** | | | ✓ | ✓ | ✓ | | Create/edit/delete metrics dashboard annotations | | | ✓ | ✓ | ✓ | | Run CI/CD pipeline against a protected branch | | | ✓ (*5*) | ✓ | ✓ | +| Delete [packages](packages/index.md) | | | | ✓ | ✓ | | Request a CVE ID **(FREE ONLY)** | | | | ✓ | ✓ | | Use environment terminals | | | | ✓ | ✓ | | Run Web IDE's Interactive Web Terminals **(ULTIMATE ONLY)** | | | | ✓ | ✓ | @@ -133,6 +144,7 @@ The following table depicts the various user permission levels in a project. | Enable/disable tag protections | | | | ✓ | ✓ | | Edit project settings | | | | ✓ | ✓ | | Edit project badges | | | | ✓ | ✓ | +| Export project | | | | ✓ | ✓ | | Share (invite) projects with groups | | | | ✓ (*8*) | ✓ (*8*)| | Add deploy keys to project | | | | ✓ | ✓ | | Configure project hooks | | | | ✓ | ✓ | @@ -144,8 +156,6 @@ The following table depicts the various user permission levels in a project. | Remove GitLab Pages | | | | ✓ | ✓ | | Manage clusters | | | | ✓ | ✓ | | Manage Project Operations | | | | ✓ | ✓ | -| View Pods logs | | | ✓ | ✓ | ✓ | -| Read Terraform state | | | ✓ | ✓ | ✓ | | Manage Terraform state | | | | ✓ | ✓ | | Manage license policy **(ULTIMATE)** | | | | ✓ | ✓ | | Edit comments (posted by any user) | | | | ✓ | ✓ | @@ -160,22 +170,12 @@ The following table depicts the various user permission levels in a project. | Remove fork relationship | | | | | ✓ | | Delete project | | | | | ✓ | | Archive project | | | | | ✓ | -| Export project | | | | ✓ | ✓ | | Delete issues | | | | | ✓ | | Delete pipelines | | | | | ✓ | | Delete merge request | | | | | ✓ | | Disable notification emails | | | | | ✓ | | Force push to protected branches (*4*) | | | | | | | Remove protected branches (*4*) | | | | | | -| View CI\CD analytics | | ✓ | ✓ | ✓ | ✓ | -| View Code Review analytics **(STARTER)** | | ✓ | ✓ | ✓ | ✓ | -| View Insights **(ULTIMATE)** | ✓ | ✓ | ✓ | ✓ | ✓ | -| View Issue analytics **(PREMIUM)** | ✓ | ✓ | ✓ | ✓ | ✓ | -| View Merge Request analytics **(STARTER)** | ✓ | ✓ | ✓ | ✓ | ✓ | -| View Repository analytics | | ✓ | ✓ | ✓ | ✓ | -| View Value Stream analytics | ✓ | ✓ | ✓ | ✓ | ✓ | - -\* Owner permission is only available at the group or personal namespace level (and for instance admins) and is inherited by its projects. 1. Guest users are able to perform this action on public and internal projects, but not private projects. This doesn't apply to [external users](#external-users) where explicit access must be given even if the project is internal. 1. Guest users can only view the confidential issues they created themselves. @@ -187,6 +187,7 @@ The following table depicts the various user permission levels in a project. 1. When [Share Group Lock](./group/index.md#share-with-group-lock) is enabled the project can't be shared with other groups. It does not affect group with group sharing. 1. For information on eligible approvers for merge requests, see [Eligible approvers](project/merge_requests/merge_request_approvals.md#eligible-approvers). +1. Owner permission is only available at the group or personal namespace level (and for instance admins) and is inherited by its projects. ## Project features permissions diff --git a/doc/user/project/static_site_editor/index.md b/doc/user/project/static_site_editor/index.md index 07825eb3df5..1147b595c8d 100644 --- a/doc/user/project/static_site_editor/index.md +++ b/doc/user/project/static_site_editor/index.md @@ -115,6 +115,17 @@ company and a new feature has been added to the company product. 1. You edit the file right there and click **Submit changes**. 1. A new merge request is automatically created and you assign it to your colleague for review. +## Videos + +> - Support for embedding YouTube videos through the WYSIWYG editor [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216642) in GitLab 13.5. + +You can embed YouTube videos on the WYSIWYG mode by clicking the video icon (**{live-preview}**). +The following URL/ID formats are supported: + +- YouTube watch URL (e.g. `https://www.youtube.com/watch?v=0t1DgySidms`) +- YouTube embed URL (e.g. `https://www.youtube.com/embed/0t1DgySidms`) +- YouTube video ID (e.g. `0t1DgySidms`) + ## Limitations - The Static Site Editor still cannot be quickly added to existing Middleman sites. Follow this [epic](https://gitlab.com/groups/gitlab-org/-/epics/2784) for updates. diff --git a/lib/banzai/filter/reference_filter.rb b/lib/banzai/filter/reference_filter.rb index d22a0e0b504..cfd4b932568 100644 --- a/lib/banzai/filter/reference_filter.rb +++ b/lib/banzai/filter/reference_filter.rb @@ -119,7 +119,7 @@ module Banzai # Yields the link's URL and inner HTML whenever the node is a valid tag. def yield_valid_link(node) - link = unescape_link(node.attr('href').to_s) + link = CGI.unescape(node.attr('href').to_s) inner_html = node.inner_html return unless link.force_encoding('UTF-8').valid_encoding? @@ -127,10 +127,6 @@ module Banzai yield link, inner_html end - def unescape_link(href) - CGI.unescape(href) - end - def replace_text_when_pattern_matches(node, index, pattern) return unless node.text =~ pattern diff --git a/lib/banzai/filter/vulnerability_reference_filter.rb b/lib/banzai/filter/vulnerability_reference_filter.rb deleted file mode 100644 index a59e9836d69..00000000000 --- a/lib/banzai/filter/vulnerability_reference_filter.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module Filter - # The actual filter is implemented in the EE mixin - class VulnerabilityReferenceFilter < IssuableReferenceFilter - self.reference_type = :vulnerability - - def self.object_class - Vulnerability - end - - private - - def project - context[:project] - end - end - end -end - -Banzai::Filter::VulnerabilityReferenceFilter.prepend_if_ee('EE::Banzai::Filter::VulnerabilityReferenceFilter') diff --git a/lib/banzai/reference_parser/vulnerability_parser.rb b/lib/banzai/reference_parser/vulnerability_parser.rb deleted file mode 100644 index 143f2605927..00000000000 --- a/lib/banzai/reference_parser/vulnerability_parser.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module ReferenceParser - # The actual parser is implemented in the EE mixin - class VulnerabilityParser < IssuableParser - self.reference_type = :vulnerability - - def records_for_nodes(_nodes) - {} - end - end - end -end - -Banzai::ReferenceParser::VulnerabilityParser.prepend_if_ee('::EE::Banzai::ReferenceParser::VulnerabilityParser') diff --git a/lib/gitlab/ci/config.rb b/lib/gitlab/ci/config.rb index 9d269831679..071a8ef830f 100644 --- a/lib/gitlab/ci/config.rb +++ b/lib/gitlab/ci/config.rb @@ -54,6 +54,10 @@ module Gitlab root.variables_value end + def variables_with_data + root.variables_entry.value_with_data + end + def stages root.stages_value end diff --git a/lib/gitlab/ci/config/entry/variables.rb b/lib/gitlab/ci/config/entry/variables.rb index c9d0c7cb568..e258f7128fc 100644 --- a/lib/gitlab/ci/config/entry/variables.rb +++ b/lib/gitlab/ci/config/entry/variables.rb @@ -10,16 +10,32 @@ module Gitlab class Variables < ::Gitlab::Config::Entry::Node include ::Gitlab::Config::Entry::Validatable + ALLOWED_VALUE_DATA = %i[value description].freeze + validations do - validates :config, variables: true + validates :config, variables: { allowed_value_data: ALLOWED_VALUE_DATA } + end + + def value + Hash[@config.map { |key, value| [key.to_s, expand_value(value)[:value]] }] end def self.default(**) {} end - def value - Hash[@config.map { |key, value| [key.to_s, value.to_s] }] + def value_with_data + Hash[@config.map { |key, value| [key.to_s, expand_value(value)] }] + end + + private + + def expand_value(value) + if value.is_a?(Hash) + { value: value[:value].to_s, description: value[:description] } + else + { value: value.to_s, description: nil } + end end end end diff --git a/lib/gitlab/ci/yaml_processor/result.rb b/lib/gitlab/ci/yaml_processor/result.rb index 6c771b220ad..52a00e41214 100644 --- a/lib/gitlab/ci/yaml_processor/result.rb +++ b/lib/gitlab/ci/yaml_processor/result.rb @@ -99,6 +99,10 @@ module Gitlab @ci_config&.to_hash&.to_yaml end + def variables_with_data + @ci_config.variables_with_data + end + private def variables diff --git a/lib/gitlab/config/entry/legacy_validation_helpers.rb b/lib/gitlab/config/entry/legacy_validation_helpers.rb index 415f6f77214..be7d26fed4e 100644 --- a/lib/gitlab/config/entry/legacy_validation_helpers.rb +++ b/lib/gitlab/config/entry/legacy_validation_helpers.rb @@ -50,6 +50,12 @@ module Gitlab variables.values.flatten(1).all?(&method(:validate_alphanumeric)) end + def validate_string_or_hash_value_variables(variables, allowed_value_data) + variables.is_a?(Hash) && + variables.keys.all?(&method(:validate_alphanumeric)) && + variables.values.all? { |value| validate_string_or_hash_value_variable(value, allowed_value_data) } + end + def validate_alphanumeric(value) validate_string(value) || validate_integer(value) end @@ -62,6 +68,14 @@ module Gitlab value.is_a?(String) || value.is_a?(Symbol) end + def validate_string_or_hash_value_variable(value, allowed_value_data) + if value.is_a?(Hash) + (value.keys - allowed_value_data).empty? && value.values.all?(&method(:validate_alphanumeric)) + else + validate_alphanumeric(value) + end + end + def validate_regexp(value) Gitlab::UntrustedRegexp::RubySyntax.valid?(value) end diff --git a/lib/gitlab/config/entry/validators.rb b/lib/gitlab/config/entry/validators.rb index a7ec98ace6e..2a386657e0b 100644 --- a/lib/gitlab/config/entry/validators.rb +++ b/lib/gitlab/config/entry/validators.rb @@ -274,6 +274,8 @@ module Gitlab def validate_each(record, attribute, value) if options[:array_values] validate_key_array_values(record, attribute, value) + elsif options[:allowed_value_data] + validate_key_hash_values(record, attribute, value, options[:allowed_value_data]) else validate_key_values(record, attribute, value) end @@ -290,6 +292,12 @@ module Gitlab record.errors.add(attribute, 'should be a hash of key value pairs, value can be an array') end end + + def validate_key_hash_values(record, attribute, value, allowed_value_data) + unless validate_string_or_hash_value_variables(value, allowed_value_data) + record.errors.add(attribute, 'should be a hash of key value pairs, value can be a hash') + end + end end class ExpressionValidator < ActiveModel::EachValidator diff --git a/lib/gitlab/reference_extractor.rb b/lib/gitlab/reference_extractor.rb index d7501fc7068..01aff48b08b 100644 --- a/lib/gitlab/reference_extractor.rb +++ b/lib/gitlab/reference_extractor.rb @@ -4,7 +4,7 @@ module Gitlab # Extract possible GFM references from an arbitrary String for further processing. class ReferenceExtractor < Banzai::ReferenceExtractor REFERABLES = %i(user issue label milestone mentioned_user mentioned_group mentioned_project - merge_request snippet commit commit_range directly_addressed_user epic iteration vulnerability).freeze + merge_request snippet commit commit_range directly_addressed_user epic iteration).freeze attr_accessor :project, :current_user, :author # This counter is increased by a number of references filtered out by # banzai reference exctractor. Note that this counter is stateful and @@ -38,7 +38,7 @@ module Gitlab end REFERABLES.each do |type| - define_method(type.to_s.pluralize) do + define_method("#{type}s") do @references[type] ||= references(type) end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 36da7340c60..ad60d2b65d6 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1051,6 +1051,9 @@ msgstr "" msgid "0 for unlimited, only effective with remote storage enabled." msgstr "" +msgid "0t1DgySidms" +msgstr "" + msgid "1 %{type} addition" msgid_plural "%{count} %{type} additions" msgstr[0] "" @@ -13549,6 +13552,9 @@ msgstr "" msgid "If enabled, access to projects will be validated on an external service using their classification label." msgstr "" +msgid "If the YouTube URL is https://www.youtube.com/watch?v=0t1DgySidms then the video ID is %{id}" +msgstr "" + msgid "If the number of active users exceeds the user limit, you will be charged for the number of %{users_over_license_link} at your next license reconciliation." msgstr "" @@ -14021,6 +14027,9 @@ msgstr "" msgid "Insert a quote" msgstr "" +msgid "Insert a video" +msgstr "" + msgid "Insert an image" msgstr "" @@ -14036,6 +14045,9 @@ msgstr "" msgid "Insert suggestion" msgstr "" +msgid "Insert video" +msgstr "" + msgid "Insights" msgstr "" @@ -19546,6 +19558,9 @@ msgstr "" msgid "Please provide a valid URL" msgstr "" +msgid "Please provide a valid YouTube URL or ID" +msgstr "" + msgid "Please provide a valid email address." msgstr "" @@ -30300,6 +30315,9 @@ msgstr "" msgid "YouTube" msgstr "" +msgid "YouTube URL or ID" +msgstr "" + msgid "Your %{host} account was signed in to from a new location" msgstr "" diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb index c3be7de25a8..0720124ea57 100644 --- a/spec/controllers/projects/pipelines_controller_spec.rb +++ b/spec/controllers/projects/pipelines_controller_spec.rb @@ -1148,4 +1148,84 @@ RSpec.describe Projects::PipelinesController do } end end + + describe 'GET config_variables.json' do + let(:result) { YAML.dump(ci_config) } + + before do + stub_gitlab_ci_yml_for_sha(sha, result) + end + + context 'when sending a valid sha' do + let(:sha) { 'master' } + let(:ci_config) do + { + variables: { + KEY1: { value: 'val 1', description: 'description 1' } + }, + test: { + stage: 'test', + script: 'echo' + } + } + end + + it 'returns variable list' do + get_config_variables + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['KEY1']).to eq({ 'value' => 'val 1', 'description' => 'description 1' }) + end + end + + context 'when sending an invalid sha' do + let(:sha) { 'invalid-sha' } + let(:ci_config) { nil } + + it 'returns empty json' do + get_config_variables + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to eq({}) + end + end + + context 'when sending an invalid config' do + let(:sha) { 'master' } + let(:ci_config) do + { + variables: { + KEY1: { value: 'val 1', description: 'description 1' } + }, + test: { + stage: 'invalid', + script: 'echo' + } + } + end + + it 'returns empty result' do + get_config_variables + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to eq({}) + end + end + + private + + def stub_gitlab_ci_yml_for_sha(sha, result) + allow_any_instance_of(Repository) + .to receive(:gitlab_ci_yml_for) + .with(sha, '.gitlab-ci.yml') + .and_return(result) + end + + def get_config_variables + get :config_variables, params: { namespace_id: project.namespace, + project_id: project, + sha: sha }, + format: :json + end + end end diff --git a/spec/controllers/projects/project_members_controller_spec.rb b/spec/controllers/projects/project_members_controller_spec.rb index 4755dab9967..74311fa89f3 100644 --- a/spec/controllers/projects/project_members_controller_spec.rb +++ b/spec/controllers/projects/project_members_controller_spec.rb @@ -573,4 +573,19 @@ RSpec.describe Projects::ProjectMembersController do end end end + + describe 'POST resend_invite' do + let(:member) { create(:project_member, project: project) } + + before do + project.add_maintainer(user) + sign_in(user) + end + + it 'is successful' do + post :resend_invite, params: { namespace_id: project.namespace, project_id: project, id: member } + + expect(response).to have_gitlab_http_status(:found) + end + end end diff --git a/spec/features/dashboard/merge_requests_spec.rb b/spec/features/dashboard/merge_requests_spec.rb index 952a78ec79a..a47f2285e37 100644 --- a/spec/features/dashboard/merge_requests_spec.rb +++ b/spec/features/dashboard/merge_requests_spec.rb @@ -52,20 +52,29 @@ RSpec.describe 'Dashboard Merge Requests' do end context 'merge requests exist' do + let_it_be(:author_user) { create(:user) } let(:label) { create(:label) } let!(:assigned_merge_request) do create(:merge_request, assignees: [current_user], source_project: project, - author: create(:user)) + author: author_user) + end + + let!(:review_requested_merge_request) do + create(:merge_request, + reviewers: [current_user], + source_branch: 'review', + source_project: project, + author: author_user) end let!(:assigned_merge_request_from_fork) do create(:merge_request, source_branch: 'markdown', assignees: [current_user], target_project: public_project, source_project: forked_project, - author: create(:user)) + author: author_user) end let!(:authored_merge_request) do @@ -94,7 +103,7 @@ RSpec.describe 'Dashboard Merge Requests' do create(:merge_request, source_branch: 'fix', source_project: project, - author: create(:user)) + author: author_user) end before do @@ -111,6 +120,10 @@ RSpec.describe 'Dashboard Merge Requests' do expect(page).not_to have_content(labeled_merge_request.title) end + it 'shows review requested merge requests' do + expect(page).to have_content(review_requested_merge_request.title) + end + it 'shows authored merge requests', :js do reset_filters input_filtered_search("author:=#{current_user.to_reference}") diff --git a/spec/features/projects/releases/user_views_release_spec.rb b/spec/features/projects/releases/user_views_release_spec.rb index 4410f345e56..186122536ce 100644 --- a/spec/features/projects/releases/user_views_release_spec.rb +++ b/spec/features/projects/releases/user_views_release_spec.rb @@ -5,6 +5,7 @@ require 'spec_helper' RSpec.describe 'User views Release', :js do let(:project) { create(:project, :repository) } let(:user) { create(:user) } + let(:graphql_feature_flag) { true } let(:release) do create(:release, @@ -14,6 +15,8 @@ RSpec.describe 'User views Release', :js do end before do + stub_feature_flags(graphql_individual_release_page: graphql_feature_flag) + project.add_developer(user) sign_in(user) @@ -23,23 +26,35 @@ RSpec.describe 'User views Release', :js do it_behaves_like 'page meta description', 'Lorem ipsum dolor sit amet' - it 'renders the breadcrumbs' do - within('.breadcrumbs') do - expect(page).to have_content("#{project.creator.name} #{project.name} Releases #{release.name}") + shared_examples 'release page' do + it 'renders the breadcrumbs' do + within('.breadcrumbs') do + expect(page).to have_content("#{project.creator.name} #{project.name} Releases #{release.name}") - expect(page).to have_link(project.creator.name, href: user_path(project.creator)) - expect(page).to have_link(project.name, href: project_path(project)) - expect(page).to have_link('Releases', href: project_releases_path(project)) - expect(page).to have_link(release.name, href: project_release_path(project, release)) + expect(page).to have_link(project.creator.name, href: user_path(project.creator)) + expect(page).to have_link(project.name, href: project_path(project)) + expect(page).to have_link('Releases', href: project_releases_path(project)) + expect(page).to have_link(release.name, href: project_release_path(project, release)) + end + end + + it 'renders the release details' do + within('.release-block') do + expect(page).to have_content(release.name) + expect(page).to have_content(release.tag) + expect(page).to have_content(release.commit.short_id) + expect(page).to have_content('Lorem ipsum dolor sit amet') + end end end - it 'renders the release details' do - within('.release-block') do - expect(page).to have_content(release.name) - expect(page).to have_content(release.tag) - expect(page).to have_content(release.commit.short_id) - expect(page).to have_content('Lorem ipsum dolor sit amet') - end + describe 'when the graphql_individual_release_page feature flag is enabled' do + it_behaves_like 'release page' + end + + describe 'when the graphql_individual_release_page feature flag is disabled' do + let(:graphql_feature_flag) { false } + + it_behaves_like 'release page' end end diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb index 64f4672104a..63d8a26af27 100644 --- a/spec/finders/merge_requests_finder_spec.rb +++ b/spec/finders/merge_requests_finder_spec.rb @@ -333,6 +333,8 @@ RSpec.describe MergeRequestsFinder do end context 'assignee filtering' do + let_it_be(:user3) { create(:user) } + let(:issuables) { described_class.new(user, params).execute } it_behaves_like 'assignee ID filter' do @@ -351,7 +353,6 @@ RSpec.describe MergeRequestsFinder do merge_request3.assignees = [user2, user3] end - let_it_be(:user3) { create(:user) } let(:params) { { assignee_username: [user2.username, user3.username] } } let(:expected_issuables) { [merge_request3] } end @@ -366,7 +367,6 @@ RSpec.describe MergeRequestsFinder do end it_behaves_like 'no assignee filter' do - let_it_be(:user3) { create(:user) } let(:expected_issuables) { [merge_request4, merge_request5] } end @@ -374,30 +374,54 @@ RSpec.describe MergeRequestsFinder do let(:expected_issuables) { [merge_request1, merge_request2, merge_request3] } end - context 'filtering by group milestone' do - let(:group_milestone) { create(:milestone, group: group) } + context 'with just reviewers' do + it_behaves_like 'assignee username filter' do + before do + merge_request4.reviewers = [user3] + merge_request4.assignees = [] + end - before do - merge_request1.update!(milestone: group_milestone) - merge_request2.update!(milestone: group_milestone) + let(:params) { { assignee_username: [user3.username] } } + let(:expected_issuables) { [merge_request4] } end + end - it 'returns merge requests assigned to that group milestone' do - params = { milestone_title: group_milestone.title } + context 'with an additional reviewer' do + it_behaves_like 'assignee username filter' do + before do + merge_request3.assignees = [user3] + merge_request4.reviewers = [user3] + end + let(:params) { { assignee_username: [user3.username] } } + let(:expected_issuables) { [merge_request3, merge_request4] } + end + end + end + + context 'filtering by group milestone' do + let(:group_milestone) { create(:milestone, group: group) } + + before do + merge_request1.update!(milestone: group_milestone) + merge_request2.update!(milestone: group_milestone) + end + + it 'returns merge requests assigned to that group milestone' do + params = { milestone_title: group_milestone.title } + + merge_requests = described_class.new(user, params).execute + + expect(merge_requests).to contain_exactly(merge_request1, merge_request2) + end + + context 'using NOT' do + let(:params) { { not: { milestone_title: group_milestone.title } } } + + it 'returns MRs not assigned to that group milestone' do merge_requests = described_class.new(user, params).execute - expect(merge_requests).to contain_exactly(merge_request1, merge_request2) - end - - context 'using NOT' do - let(:params) { { not: { milestone_title: group_milestone.title } } } - - it 'returns MRs not assigned to that group milestone' do - merge_requests = described_class.new(user, params).execute - - expect(merge_requests).to contain_exactly(merge_request3, merge_request4, merge_request5) - end + expect(merge_requests).to contain_exactly(merge_request3, merge_request4, merge_request5) end end end diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 5a50cc063f9..8da4320d993 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -8,228 +8,15 @@ import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete import { TEST_HOST } from 'helpers/test_constants'; import { getJSONFixture } from 'helpers/fixtures'; -import waitForPromises from 'jest/helpers/wait_for_promises'; - -import MockAdapter from 'axios-mock-adapter'; -import AjaxCache from '~/lib/utils/ajax_cache'; -import axios from '~/lib/utils/axios_utils'; - const labelsFixture = getJSONFixture('autocomplete_sources/labels.json'); describe('GfmAutoComplete', () => { - const fetchDataMock = { fetchData: jest.fn() }; - let gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call(fetchDataMock); + const gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({ + fetchData: () => {}, + }); let atwhoInstance; let sorterValue; - let filterValue; - - describe('.typesWithBackendFiltering', () => { - it('should contain vulnerabilities', () => { - expect(GfmAutoComplete.typesWithBackendFiltering).toContain('vulnerabilities'); - }); - }); - - describe('DefaultOptions.filter', () => { - let items; - - beforeEach(() => { - jest.spyOn(fetchDataMock, 'fetchData'); - jest.spyOn($.fn.atwho.default.callbacks, 'filter').mockImplementation(() => {}); - }); - - describe('assets loading', () => { - beforeEach(() => { - atwhoInstance = { setting: {}, $inputor: 'inputor', at: '+' }; - items = ['loading']; - - filterValue = gfmAutoCompleteCallbacks.filter.call(atwhoInstance, '', items); - }); - - it('should call the fetchData function without query', () => { - expect(fetchDataMock.fetchData).toHaveBeenCalledWith('inputor', '+'); - }); - - it('should not call the default atwho filter', () => { - expect($.fn.atwho.default.callbacks.filter).not.toHaveBeenCalled(); - }); - - it('should return the passed unfiltered items', () => { - expect(filterValue).toEqual(items); - }); - }); - - describe('backend filtering', () => { - beforeEach(() => { - atwhoInstance = { setting: {}, $inputor: 'inputor', at: '+' }; - items = []; - }); - - describe('when previous query is different from current one', () => { - beforeEach(() => { - gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({ - previousQuery: 'oldquery', - ...fetchDataMock, - }); - filterValue = gfmAutoCompleteCallbacks.filter.call(atwhoInstance, 'newquery', items); - }); - - it('should call the fetchData function with query', () => { - expect(fetchDataMock.fetchData).toHaveBeenCalledWith('inputor', '+', 'newquery'); - }); - - it('should not call the default atwho filter', () => { - expect($.fn.atwho.default.callbacks.filter).not.toHaveBeenCalled(); - }); - - it('should return the passed unfiltered items', () => { - expect(filterValue).toEqual(items); - }); - }); - - describe('when previous query is not different from current one', () => { - beforeEach(() => { - gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({ - previousQuery: 'oldquery', - ...fetchDataMock, - }); - filterValue = gfmAutoCompleteCallbacks.filter.call( - atwhoInstance, - 'oldquery', - items, - 'searchKey', - ); - }); - - it('should not call the fetchData function', () => { - expect(fetchDataMock.fetchData).not.toHaveBeenCalled(); - }); - - it('should call the default atwho filter', () => { - expect($.fn.atwho.default.callbacks.filter).toHaveBeenCalledWith( - 'oldquery', - items, - 'searchKey', - ); - }); - }); - }); - }); - - describe('fetchData', () => { - const { fetchData } = GfmAutoComplete.prototype; - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - jest.spyOn(axios, 'get'); - jest.spyOn(AjaxCache, 'retrieve'); - }); - - afterEach(() => { - mock.restore(); - }); - - describe('already loading data', () => { - beforeEach(() => { - const context = { - isLoadingData: { '+': true }, - dataSources: {}, - cachedData: {}, - }; - fetchData.call(context, {}, '+', ''); - }); - - it('should not call either axios nor AjaxCache', () => { - expect(axios.get).not.toHaveBeenCalled(); - expect(AjaxCache.retrieve).not.toHaveBeenCalled(); - }); - }); - - describe('backend filtering', () => { - describe('data is not in cache', () => { - let context; - - beforeEach(() => { - context = { - isLoadingData: { '+': false }, - dataSources: { vulnerabilities: 'vulnerabilities_autocomplete_url' }, - cachedData: {}, - }; - }); - - it('should call axios with query', () => { - fetchData.call(context, {}, '+', 'query'); - - expect(axios.get).toHaveBeenCalledWith('vulnerabilities_autocomplete_url', { - params: { search: 'query' }, - }); - }); - - it.each([200, 500])('should set the loading state', async responseStatus => { - mock.onGet('vulnerabilities_autocomplete_url').replyOnce(responseStatus); - - fetchData.call(context, {}, '+', 'query'); - - expect(context.isLoadingData['+']).toBe(true); - - await waitForPromises(); - - expect(context.isLoadingData['+']).toBe(false); - }); - }); - - describe('data is in cache', () => { - beforeEach(() => { - const context = { - isLoadingData: { '+': false }, - dataSources: { vulnerabilities: 'vulnerabilities_autocomplete_url' }, - cachedData: { '+': [{}] }, - }; - fetchData.call(context, {}, '+', 'query'); - }); - - it('should anyway call axios with query ignoring cache', () => { - expect(axios.get).toHaveBeenCalledWith('vulnerabilities_autocomplete_url', { - params: { search: 'query' }, - }); - }); - }); - }); - - describe('frontend filtering', () => { - describe('data is not in cache', () => { - beforeEach(() => { - const context = { - isLoadingData: { '#': false }, - dataSources: { issues: 'issues_autocomplete_url' }, - cachedData: {}, - }; - fetchData.call(context, {}, '#', 'query'); - }); - - it('should call AjaxCache', () => { - expect(AjaxCache.retrieve).toHaveBeenCalledWith('issues_autocomplete_url', true); - }); - }); - - describe('data is in cache', () => { - beforeEach(() => { - const context = { - isLoadingData: { '#': false }, - dataSources: { issues: 'issues_autocomplete_url' }, - cachedData: { '#': [{}] }, - loadData: () => {}, - }; - fetchData.call(context, {}, '#', 'query'); - }); - - it('should not call AjaxCache', () => { - expect(AjaxCache.retrieve).not.toHaveBeenCalled(); - }); - }); - }); - }); describe('DefaultOptions.sorter', () => { describe('assets loading', () => { @@ -333,7 +120,7 @@ describe('GfmAutoComplete', () => { const defaultMatcher = (context, flag, subtext) => gfmAutoCompleteCallbacks.matcher.call(context, flag, subtext); - const flagsUseDefaultMatcher = ['@', '#', '!', '~', '%', '$', '+']; + const flagsUseDefaultMatcher = ['@', '#', '!', '~', '%', '$']; const otherFlags = ['/', ':']; const flags = flagsUseDefaultMatcher.concat(otherFlags); @@ -367,6 +154,7 @@ describe('GfmAutoComplete', () => { 'я', '.', "'", + '+', '-', '_', ]; diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js index 99365a037a4..709f66bb352 100644 --- a/spec/frontend/incidents/components/incidents_list_spec.js +++ b/spec/frontend/incidents/components/incidents_list_spec.js @@ -10,6 +10,8 @@ import { TH_CREATED_AT_TEST_ID, TH_SEVERITY_TEST_ID, TH_PUBLISHED_TEST_ID, + trackIncidentCreateNewOptions, + trackIncidentListViewsOptions, } from '~/incidents/constants'; import mockIncidents from '../mocks/incidents.json'; @@ -291,4 +293,25 @@ describe('Incidents List', () => { expect(columnHeader().attributes('aria-sort')).toBe(nextSort); }); }); + + describe('Snowplow tracking', () => { + beforeEach(() => { + mountComponent({ + data: { incidents: { list: mockIncidents }, incidentsCount: {} }, + loading: false, + }); + }); + + it('should track incident list views', () => { + const { category, action } = trackIncidentListViewsOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action); + }); + + it('should track incident creation events', async () => { + findCreateIncidentBtn().vm.$emit('click'); + await wrapper.vm.$nextTick(); + const { category, action } = trackIncidentCreateNewOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action); + }); + }); }); diff --git a/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js b/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js index 9b22fe4e85a..c6200fd69bf 100644 --- a/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js +++ b/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js @@ -6,6 +6,8 @@ import { descriptionProps } from '../../mock_data'; import DescriptionComponent from '~/issue_show/components/description.vue'; import HighlightBar from '~/issue_show/components/incidents/highlight_bar.vue'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; +import Tracking from '~/tracking'; +import { trackIncidentDetailsViewsOptions } from '~/incidents/constants'; const mockAlert = { __typename: 'AlertManagementAlert', @@ -97,4 +99,16 @@ describe('Incident Tabs component', () => { expect(findDescriptionComponent().props()).toMatchObject(descriptionProps); }); }); + + describe('Snowplow tracking', () => { + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + mountComponent(); + }); + + it('should track incident details views', () => { + const { category, action } = trackIncidentDetailsViewsOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action); + }); + }); }); diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js index 0057fa2d8d6..d38f6766d4e 100644 --- a/spec/frontend/releases/stores/modules/detail/actions_spec.js +++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js @@ -34,6 +34,12 @@ describe('Release detail actions', () => { isExistingRelease: true, }; + const rootState = { + featureFlags: { + graphqlIndividualReleasePage: false, + }, + }; + state = { ...createState({ projectId: '18', @@ -44,6 +50,7 @@ describe('Release detail actions', () => { updateReleaseApiDocsPath: 'path/to/api/docs', }), ...getters, + ...rootState, ...updates, }; }; @@ -154,7 +161,7 @@ describe('Release detail actions', () => { }); it(`shows a flash message`, () => { - return actions.fetchRelease({ commit: jest.fn(), state }).then(() => { + return actions.fetchRelease({ commit: jest.fn(), state, rootState: state }).then(() => { expect(createFlash).toHaveBeenCalledTimes(1); expect(createFlash).toHaveBeenCalledWith( 'Something went wrong while getting the release details', diff --git a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js index 81d31a284df..0f2f263a776 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js @@ -4,6 +4,7 @@ import { removeCustomEventListener, registerHTMLToMarkdownRenderer, addImage, + insertVideo, getMarkdown, getEditorOptions, } from '~/vue_shared/components/rich_content_editor/services/editor_service'; @@ -19,11 +20,21 @@ describe('Editor Service', () => { let mockInstance; let event; let handler; + const parseHtml = str => { + const wrapper = document.createElement('div'); + wrapper.innerHTML = str; + return wrapper.firstChild; + }; beforeEach(() => { mockInstance = { eventManager: { addEventType: jest.fn(), removeEventHandler: jest.fn(), listen: jest.fn() }, - editor: { exec: jest.fn() }, + editor: { + exec: jest.fn(), + isWysiwygMode: jest.fn(), + getSquire: jest.fn(), + insertText: jest.fn(), + }, invoke: jest.fn(), toMarkOptions: { renderer: { @@ -89,6 +100,38 @@ describe('Editor Service', () => { }); }); + describe('insertVideo', () => { + const mockUrl = 'some/url'; + const htmlString = `
`; + const mockInsertElement = jest.fn(); + + beforeEach(() => + mockInstance.editor.getSquire.mockReturnValue({ insertElement: mockInsertElement }), + ); + + describe('WYSIWYG mode', () => { + it('calls the insertElement method on the squire instance with an iFrame element', () => { + mockInstance.editor.isWysiwygMode.mockReturnValue(true); + + insertVideo(mockInstance, mockUrl); + + expect(mockInstance.editor.getSquire().insertElement).toHaveBeenCalledWith( + parseHtml(htmlString), + ); + }); + }); + + describe('Markdown mode', () => { + it('calls the insertText method on the editor instance with the iFrame element HTML', () => { + mockInstance.editor.isWysiwygMode.mockReturnValue(false); + + insertVideo(mockInstance, mockUrl); + + expect(mockInstance.editor.insertText).toHaveBeenCalledWith(htmlString); + }); + }); + }); + describe('getMarkdown', () => { it('calls the invoke method on the instance', () => { getMarkdown(mockInstance); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/modals/insert_video_modal_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/modals/insert_video_modal_spec.js new file mode 100644 index 00000000000..be3a4030b1d --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/modals/insert_video_modal_spec.js @@ -0,0 +1,44 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; +import InsertVideoModal from '~/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue'; + +describe('Insert Video Modal', () => { + let wrapper; + + const findModal = () => wrapper.find(GlModal); + const findUrlInput = () => wrapper.find({ ref: 'urlInput' }); + + const triggerInsertVideo = url => { + const preventDefault = jest.fn(); + findUrlInput().vm.$emit('input', url); + findModal().vm.$emit('primary', { preventDefault }); + }; + + beforeEach(() => { + wrapper = shallowMount(InsertVideoModal); + }); + + afterEach(() => wrapper.destroy()); + + describe('when content is loaded', () => { + it('renders a modal component', () => { + expect(findModal().exists()).toBe(true); + }); + + it('renders an input to add a URL', () => { + expect(findUrlInput().exists()).toBe(true); + }); + }); + + describe('insert video', () => { + it.each` + url | emitted + ${'https://www.youtube.com/embed/someId'} | ${[['https://www.youtube.com/embed/someId']]} + ${'https://www.youtube.com/watch?v=1234'} | ${[['https://www.youtube.com/embed/1234']]} + ${'::youtube.com/invalid/url'} | ${undefined} + `('formats the url correctly', ({ url, emitted }) => { + triggerInsertVideo(url); + expect(wrapper.emitted('insertVideo')).toEqual(emitted); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js index 3d54db7fe5c..8c2c0413819 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js @@ -1,6 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue'; +import InsertVideoModal from '~/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue'; import { EDITOR_TYPES, EDITOR_HEIGHT, @@ -12,6 +13,7 @@ import { addCustomEventListener, removeCustomEventListener, addImage, + insertVideo, registerHTMLToMarkdownRenderer, getEditorOptions, } from '~/vue_shared/components/rich_content_editor/services/editor_service'; @@ -21,6 +23,7 @@ jest.mock('~/vue_shared/components/rich_content_editor/services/editor_service', addCustomEventListener: jest.fn(), removeCustomEventListener: jest.fn(), addImage: jest.fn(), + insertVideo: jest.fn(), registerHTMLToMarkdownRenderer: jest.fn(), getEditorOptions: jest.fn(), })); @@ -32,6 +35,7 @@ describe('Rich Content Editor', () => { const imageRoot = 'path/to/root/'; const findEditor = () => wrapper.find({ ref: 'editor' }); const findAddImageModal = () => wrapper.find(AddImageModal); + const findInsertVideoModal = () => wrapper.find(InsertVideoModal); const buildWrapper = () => { wrapper = shallowMount(RichContentEditor, { @@ -122,6 +126,14 @@ describe('Rich Content Editor', () => { ); }); + it('adds the CUSTOM_EVENTS.openInsertVideoModal custom event listener', () => { + expect(addCustomEventListener).toHaveBeenCalledWith( + wrapper.vm.editorApi, + CUSTOM_EVENTS.openInsertVideoModal, + wrapper.vm.onOpenInsertVideoModal, + ); + }); + it('registers HTML to markdown renderer', () => { expect(registerHTMLToMarkdownRenderer).toHaveBeenCalledWith(wrapper.vm.editorApi); }); @@ -141,6 +153,16 @@ describe('Rich Content Editor', () => { wrapper.vm.onOpenAddImageModal, ); }); + + it('removes the CUSTOM_EVENTS.openInsertVideoModal custom event listener', () => { + wrapper.vm.$destroy(); + + expect(removeCustomEventListener).toHaveBeenCalledWith( + wrapper.vm.editorApi, + CUSTOM_EVENTS.openInsertVideoModal, + wrapper.vm.onOpenInsertVideoModal, + ); + }); }); describe('add image modal', () => { @@ -161,4 +183,23 @@ describe('Rich Content Editor', () => { expect(addImage).toHaveBeenCalledWith(mockInstance, mockImage); }); }); + + describe('insert video modal', () => { + beforeEach(() => { + buildWrapper(); + }); + + it('renders an insertVideoModal component', () => { + expect(findInsertVideoModal().exists()).toBe(true); + }); + + it('calls the onInsertVideo method when the insertVideo event is emitted', () => { + const mockUrl = 'https://www.youtube.com/embed/someId'; + const mockInstance = { exec: jest.fn() }; + wrapper.vm.$refs.editor = mockInstance; + + findInsertVideoModal().vm.$emit('insertVideo', mockUrl); + expect(insertVideo).toHaveBeenCalledWith(mockInstance, mockUrl); + }); + }); }); diff --git a/spec/helpers/releases_helper_spec.rb b/spec/helpers/releases_helper_spec.rb index f10a2ed8e60..704e8dc40cb 100644 --- a/spec/helpers/releases_helper_spec.rb +++ b/spec/helpers/releases_helper_spec.rb @@ -64,6 +64,7 @@ RSpec.describe ReleasesHelper do describe '#data_for_edit_release_page' do it 'has the needed data to display the "edit release" page' do keys = %i(project_id + project_path tag_name markdown_preview_path markdown_docs_path @@ -80,6 +81,7 @@ RSpec.describe ReleasesHelper do describe '#data_for_new_release_page' do it 'has the needed data to display the "new release" page' do keys = %i(project_id + project_path releases_page_path markdown_preview_path markdown_docs_path @@ -92,5 +94,15 @@ RSpec.describe ReleasesHelper do expect(helper.data_for_new_release_page.keys).to match_array(keys) end end + + describe '#data_for_show_page' do + it 'has the needed data to display the individual "release" page' do + keys = %i(project_id + project_path + tag_name) + + expect(helper.data_for_show_page.keys).to match_array(keys) + end + end end end diff --git a/spec/lib/gitlab/ci/config/entry/variables_spec.rb b/spec/lib/gitlab/ci/config/entry/variables_spec.rb index d6391092f63..ac33f858f43 100644 --- a/spec/lib/gitlab/ci/config/entry/variables_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/variables_spec.rb @@ -3,56 +3,109 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Config::Entry::Variables do - let(:entry) { described_class.new(config) } + subject { described_class.new(config) } - describe 'validations' do - context 'when entry config value is correct' do - let(:config) do - { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' } - end - - describe '#value' do - it 'returns hash with key value strings' do - expect(entry.value).to eq config - end - - context 'with numeric keys and values in the config' do - let(:config) { { 10 => 20 } } - - it 'converts numeric key and numeric value into strings' do - expect(entry.value).to eq('10' => '20') - end - end - end - - describe '#errors' do - it 'does not append errors' do - expect(entry.errors).to be_empty - end - end - - describe '#valid?' do - it 'is valid' do - expect(entry).to be_valid - end + shared_examples 'valid config' do + describe '#value' do + it 'returns hash with key value strings' do + expect(subject.value).to eq result end end - context 'when entry value is not correct' do - let(:config) { [:VAR, 'test'] } - - describe '#errors' do - it 'saves errors' do - expect(entry.errors) - .to include /should be a hash of key value pairs/ - end + describe '#errors' do + it 'does not append errors' do + expect(subject.errors).to be_empty end + end - describe '#valid?' do - it 'is not valid' do - expect(entry).not_to be_valid - end + describe '#valid?' do + it 'is valid' do + expect(subject).to be_valid end end end + + shared_examples 'invalid config' do + describe '#valid?' do + it 'is not valid' do + expect(subject).not_to be_valid + end + end + + describe '#errors' do + it 'saves errors' do + expect(subject.errors) + .to include /should be a hash of key value pairs/ + end + end + end + + context 'when entry config value has key-value pairs' do + let(:config) do + { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' } + end + + let(:result) do + { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' } + end + + it_behaves_like 'valid config' + end + + context 'with numeric keys and values in the config' do + let(:config) { { 10 => 20 } } + let(:result) do + { '10' => '20' } + end + + it_behaves_like 'valid config' + end + + context 'when entry config value has key-value pair and hash' do + let(:config) do + { 'VARIABLE_1' => { value: 'value 1', description: 'variable 1' }, + 'VARIABLE_2' => 'value 2' } + end + + let(:result) do + { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' } + end + + it_behaves_like 'valid config' + end + + context 'when entry value is an array' do + let(:config) { [:VAR, 'test'] } + + it_behaves_like 'invalid config' + end + + context 'when entry value has hash with other key-pairs' do + let(:config) do + { 'VARIABLE_1' => { value: 'value 1', hello: 'variable 1' }, + 'VARIABLE_2' => 'value 2' } + end + + it_behaves_like 'invalid config' + end + + context 'when entry config value has hash with nil description' do + let(:config) do + { 'VARIABLE_1' => { value: 'value 1', description: nil } } + end + + it_behaves_like 'invalid config' + end + + context 'when entry config value has hash without description' do + let(:config) do + { 'VARIABLE_1' => { value: 'value 1' } } + end + + let(:result) do + { 'VARIABLE_1' => 'value 1' } + end + + it_behaves_like 'valid config' + end end diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index 03579d0936c..fb6395e888a 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -2465,13 +2465,13 @@ module Gitlab context 'returns errors if variables is not a map' do let(:config) { YAML.dump({ variables: "test", rspec: { script: "test" } }) } - it_behaves_like 'returns errors', 'variables config should be a hash of key value pairs' + it_behaves_like 'returns errors', 'variables config should be a hash of key value pairs, value can be a hash' end context 'returns errors if variables is not a map of key-value strings' do let(:config) { YAML.dump({ variables: { test: false }, rspec: { script: "test" } }) } - it_behaves_like 'returns errors', 'variables config should be a hash of key value pairs' + it_behaves_like 'returns errors', 'variables config should be a hash of key value pairs, value can be a hash' end context 'returns errors if job when is not on_success, on_failure or always' do diff --git a/spec/lib/gitlab/reference_extractor_spec.rb b/spec/lib/gitlab/reference_extractor_spec.rb index ee237e5f267..0172defc75d 100644 --- a/spec/lib/gitlab/reference_extractor_spec.rb +++ b/spec/lib/gitlab/reference_extractor_spec.rb @@ -296,7 +296,7 @@ RSpec.describe Gitlab::ReferenceExtractor do end it 'returns all supported prefixes' do - expect(prefixes.keys.uniq).to match_array(%w(@ # ~ % ! $ & + *iteration:)) + expect(prefixes.keys.uniq).to match_array(%w(@ # ~ % ! $ & *iteration:)) end it 'does not allow one prefix for multiple referables if not allowed specificly' do diff --git a/spec/models/clusters/applications/fluentd_spec.rb b/spec/models/clusters/applications/fluentd_spec.rb index be7b4a87947..3bda3e99ec1 100644 --- a/spec/models/clusters/applications/fluentd_spec.rb +++ b/spec/models/clusters/applications/fluentd_spec.rb @@ -25,7 +25,7 @@ RSpec.describe Clusters::Applications::Fluentd do it 'is initialized with fluentd arguments' do expect(subject.name).to eq('fluentd') - expect(subject.chart).to eq('stable/fluentd') + expect(subject.chart).to eq('fluentd/fluentd') expect(subject.version).to eq('2.4.0') expect(subject).to be_rbac end diff --git a/spec/models/clusters/applications/ingress_spec.rb b/spec/models/clusters/applications/ingress_spec.rb index e029283326f..196d57aff7b 100644 --- a/spec/models/clusters/applications/ingress_spec.rb +++ b/spec/models/clusters/applications/ingress_spec.rb @@ -135,7 +135,7 @@ RSpec.describe Clusters::Applications::Ingress do it 'is initialized with ingress arguments' do expect(subject.name).to eq('ingress') - expect(subject.chart).to eq('stable/nginx-ingress') + expect(subject.chart).to eq('ingress/nginx-ingress') expect(subject.version).to eq('1.40.2') expect(subject).to be_rbac expect(subject.files).to eq(ingress.files) diff --git a/spec/models/clusters/applications/prometheus_spec.rb b/spec/models/clusters/applications/prometheus_spec.rb index 82971596176..b450900bee6 100644 --- a/spec/models/clusters/applications/prometheus_spec.rb +++ b/spec/models/clusters/applications/prometheus_spec.rb @@ -152,7 +152,7 @@ RSpec.describe Clusters::Applications::Prometheus do it 'is initialized with 3 arguments' do expect(subject.name).to eq('prometheus') - expect(subject.chart).to eq('stable/prometheus') + expect(subject.chart).to eq('prometheus/prometheus') expect(subject.version).to eq('10.4.1') expect(subject).to be_rbac expect(subject.files).to eq(prometheus.files) @@ -240,7 +240,7 @@ RSpec.describe Clusters::Applications::Prometheus do it 'is initialized with 3 arguments' do expect(patch_command.name).to eq('prometheus') - expect(patch_command.chart).to eq('stable/prometheus') + expect(patch_command.chart).to eq('prometheus/prometheus') expect(patch_command.version).to eq('10.4.1') expect(patch_command.files).to eq(prometheus.files) end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 17480584812..53a213891e9 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -5509,12 +5509,13 @@ RSpec.describe Project do describe '#find_or_initialize_services' do it 'returns only enabled services' do allow(Service).to receive(:available_services_names).and_return(%w[prometheus pushover teamcity]) + allow(Service).to receive(:project_specific_services_names).and_return(%w[asana]) allow(subject).to receive(:disabled_services).and_return(%w[prometheus]) services = subject.find_or_initialize_services - expect(services.count).to eq(2) - expect(services.map(&:title)).to eq(['JetBrains TeamCity CI', 'Pushover']) + expect(services.count).to eq(3) + expect(services.map(&:title)).to eq(['Asana', 'JetBrains TeamCity CI', 'Pushover']) end end diff --git a/spec/services/ci/list_config_variables_service_spec.rb b/spec/services/ci/list_config_variables_service_spec.rb new file mode 100644 index 00000000000..5cc0481768b --- /dev/null +++ b/spec/services/ci/list_config_variables_service_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::ListConfigVariablesService do + let_it_be(:project) { create(:project, :repository) } + let(:service) { described_class.new(project) } + let(:result) { YAML.dump(ci_config) } + + subject { service.execute(sha) } + + before do + stub_gitlab_ci_yml_for_sha(sha, result) + end + + context 'when sending a valid sha' do + let(:sha) { 'master' } + let(:ci_config) do + { + variables: { + KEY1: { value: 'val 1', description: 'description 1' }, + KEY2: { value: 'val 2', description: '' }, + KEY3: { value: 'val 3' }, + KEY4: 'val 4' + }, + test: { + stage: 'test', + script: 'echo' + } + } + end + + it 'returns variable list' do + expect(subject['KEY1']).to eq({ value: 'val 1', description: 'description 1' }) + expect(subject['KEY2']).to eq({ value: 'val 2', description: '' }) + expect(subject['KEY3']).to eq({ value: 'val 3', description: nil }) + expect(subject['KEY4']).to eq({ value: 'val 4', description: nil }) + end + end + + context 'when sending an invalid sha' do + let(:sha) { 'invalid-sha' } + let(:ci_config) { nil } + + it 'returns empty json' do + expect(subject).to eq({}) + end + end + + context 'when sending an invalid config' do + let(:sha) { 'master' } + let(:ci_config) do + { + variables: { + KEY1: { value: 'val 1', description: 'description 1' } + }, + test: { + stage: 'invalid', + script: 'echo' + } + } + end + + it 'returns empty result' do + expect(subject).to eq({}) + end + end + + private + + def stub_gitlab_ci_yml_for_sha(sha, result) + allow_any_instance_of(Repository) + .to receive(:gitlab_ci_yml_for) + .with(sha, '.gitlab-ci.yml') + .and_return(result) + end +end