diff options
169 files changed, 2667 insertions, 995 deletions
diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue index 4f433bd8dfd..eb7f45cba6f 100644 --- a/app/assets/javascripts/blob/components/blob_content.vue +++ b/app/assets/javascripts/blob/components/blob_content.vue @@ -66,6 +66,7 @@ export default { ref="contentViewer" :content="content" :type="activeViewer.fileType" + data-qa-selector="file_content" /> </template> </div> diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue index 0b98f9c0101..9b1c45a3a49 100644 --- a/app/assets/javascripts/clusters_list/components/clusters.vue +++ b/app/assets/javascripts/clusters_list/components/clusters.vue @@ -43,17 +43,17 @@ export default { key: 'environment_scope', label: __('Environment scope'), }, - // Wait for backend to send these fields - // { - // key: 'size', - // label: __('Size'), - // }, + { + key: 'node_size', + label: __('Nodes'), + }, + // Fields are missing calculation methods and not ready to display // { - // key: 'cpu', + // key: 'node_cpu', // label: __('Total cores (vCPUs)'), // }, // { - // key: 'memory', + // key: 'node_memory', // label: __('Total memory (GB)'), // }, { @@ -111,6 +111,14 @@ export default { ></div> </div> </template> + + <template #cell(node_size)="{ item }"> + <span v-if="item.nodes">{{ item.nodes.length }}</span> + <small v-else class="gl-font-sm gl-font-style-italic gl-text-gray-400">{{ + __('Unknown') + }}</small> + </template> + <template #cell(cluster_type)="{value}"> <gl-badge variant="light"> {{ value }} diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index eebcaa086f9..077bf0b8925 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -6,6 +6,8 @@ export const CLUSTER_TYPES = { instance_type: __('Instance'), }; +export const MAX_REQUESTS = 3; + export const STATUSES = { default: { className: 'bg-white', title: __('Unknown') }, disabled: { className: 'disabled', title: __('Disabled') }, diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js index 919625f69b4..5245c307c8c 100644 --- a/app/assets/javascripts/clusters_list/store/actions.js +++ b/app/assets/javascripts/clusters_list/store/actions.js @@ -2,10 +2,23 @@ import Poll from '~/lib/utils/poll'; import axios from '~/lib/utils/axios_utils'; import flash from '~/flash'; import { __ } from '~/locale'; +import { MAX_REQUESTS } from '../constants'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import * as Sentry from '@sentry/browser'; import * as types from './mutation_types'; +const allNodesPresent = (clusters, retryCount) => { + /* + Nodes are coming from external Kubernetes clusters. + They may fail for reasons GitLab cannot control. + MAX_REQUESTS will ensure this poll stops at some point. + */ + return retryCount > MAX_REQUESTS || clusters.every(cluster => cluster.nodes != null); +}; + export const fetchClusters = ({ state, commit }) => { + let retryCount = 0; + const poll = new Poll({ resource: { fetchClusters: paginatedEndPoint => axios.get(paginatedEndPoint), @@ -13,16 +26,40 @@ export const fetchClusters = ({ state, commit }) => { data: `${state.endpoint}?page=${state.page}`, method: 'fetchClusters', successCallback: ({ data, headers }) => { - if (data.clusters) { - const normalizedHeaders = normalizeHeaders(headers); - const paginationInformation = parseIntPagination(normalizedHeaders); + retryCount += 1; + + try { + if (data.clusters) { + const normalizedHeaders = normalizeHeaders(headers); + const paginationInformation = parseIntPagination(normalizedHeaders); + + commit(types.SET_CLUSTERS_DATA, { data, paginationInformation }); + commit(types.SET_LOADING_STATE, false); - commit(types.SET_CLUSTERS_DATA, { data, paginationInformation }); - commit(types.SET_LOADING_STATE, false); + if (allNodesPresent(data.clusters, retryCount)) { + poll.stop(); + } + } + } catch (error) { poll.stop(); + + Sentry.withScope(scope => { + scope.setTag('javascript_clusters_list', 'fetchClustersSuccessCallback'); + Sentry.captureException(error); + }); } }, - errorCallback: () => flash(__('An error occurred while loading clusters')), + errorCallback: response => { + poll.stop(); + + commit(types.SET_LOADING_STATE, false); + flash(__('Clusters|An error occurred while loading clusters')); + + Sentry.withScope(scope => { + scope.setTag('javascript_clusters_list', 'fetchClustersErrorCallback'); + Sentry.captureException(response); + }); + }, }); poll.makeRequest(); diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js index 011b37e218d..d8c2c6d79c6 100644 --- a/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js +++ b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js @@ -1,13 +1,10 @@ import { __ } from '~/locale'; -class RecentSearchesServiceError { +class RecentSearchesServiceError extends Error { constructor(message) { + super(message || __('Recent Searches Service is unavailable')); this.name = 'RecentSearchesServiceError'; - this.message = message || __('Recent Searches Service is unavailable'); } } -// Can't use `extends` for builtin prototypes and get true inheritance yet -RecentSearchesServiceError.prototype = Error.prototype; - export default RecentSearchesServiceError; diff --git a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue index a23bae8e4c7..a13ca0cd138 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue @@ -9,10 +9,7 @@ export default { </script> <template> - <div - v-if="!lastCommitMsg" - class="multi-file-commit-panel-section ide-commit-empty-state js-empty-state" - > + <div v-if="!lastCommitMsg" class="multi-file-commit-panel-section ide-commit-empty-state"> <div class="ide-commit-empty-state-container"> <div class="svg-content svg-80"><img :src="noChangesStateSvgPath" /></div> <div class="append-right-default prepend-left-default"> diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index 530fba49df2..b6c839269e3 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -14,12 +14,12 @@ export default { tooltip, }, computed: { - ...mapState(['changedFiles', 'stagedFiles', 'lastCommitMsg', 'unusedSeal']), + ...mapState(['changedFiles', 'stagedFiles', 'lastCommitMsg']), ...mapState('commit', ['commitMessage', 'submitCommitLoading']), ...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommittedChanges', 'activeFile']), ...mapGetters('commit', ['discardDraftButtonDisabled']), showStageUnstageArea() { - return Boolean(this.someUncommittedChanges || this.lastCommitMsg || !this.unusedSeal); + return Boolean(this.someUncommittedChanges || this.lastCommitMsg); }, activeFileKey() { return this.activeFile ? this.activeFile.key : null; @@ -67,6 +67,6 @@ export default { icon-name="unstaged" /> </template> - <empty-state v-if="unusedSeal" /> + <empty-state v-else /> </div> </template> diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index 12ac10df206..0432d87fd76 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -207,8 +207,6 @@ export default { state.changedFiles = state.changedFiles.concat(entry); } } - - state.unusedSeal = false; }, [types.RENAME_ENTRY](state, { path, name, parentPath }) { const oldEntry = state.entries[path]; diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index 5c5920a3027..313fa1fe029 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -153,13 +153,11 @@ export default { [types.ADD_FILE_TO_CHANGED](state, path) { Object.assign(state, { changedFiles: state.changedFiles.concat(state.entries[path]), - unusedSeal: false, }); }, [types.REMOVE_FILE_FROM_CHANGED](state, path) { Object.assign(state, { changedFiles: state.changedFiles.filter(f => f.path !== path), - unusedSeal: false, }); }, [types.STAGE_CHANGE](state, { path, diffInfo }) { @@ -175,7 +173,6 @@ export default { deleted: diffInfo.deleted, }), }), - unusedSeal: false, }); if (stagedFile) { diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index 0c95c22e8f8..8f3bc68d15d 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -20,7 +20,6 @@ export default () => ({ viewer: viewerTypes.edit, delayViewerUpdated: false, currentActivityView: leftSidebarViews.edit.name, - unusedSeal: true, fileFindVisible: false, links: {}, errorMessage: null, diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index f0967e77faf..d567f34fa9d 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -6,6 +6,7 @@ import { addDelimiter } from './lib/utils/text_utility'; import flash from './flash'; import CreateMergeRequestDropdown from './create_merge_request_dropdown'; import IssuablesHelper from './helpers/issuables_helper'; +import { joinPaths } from '~/lib/utils/url_utility'; import { __ } from './locale'; export default class Issue { @@ -14,6 +15,16 @@ export default class Issue { if ($('.js-close-blocked-issue-warning').length) this.initIssueWarningBtnEventListener(); + if ($('.js-alert-moved-from-service-desk-warning').length) { + const trimmedPathname = window.location.pathname.slice(1); + this.alertMovedFromServiceDeskDismissedKey = joinPaths( + trimmedPathname, + 'alert-issue-moved-from-service-desk-dismissed', + ); + + this.initIssueMovedFromServiceDeskDismissHandler(); + } + Issue.$btnNewBranch = $('#new-branch'); Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap'); @@ -167,6 +178,21 @@ export default class Issue { }); } + initIssueMovedFromServiceDeskDismissHandler() { + const alertMovedFromServiceDeskWarning = $('.js-alert-moved-from-service-desk-warning'); + + if (!localStorage.getItem(this.alertMovedFromServiceDeskDismissedKey)) { + alertMovedFromServiceDeskWarning.show(); + } + + alertMovedFromServiceDeskWarning.on('click', '.js-close', e => { + e.preventDefault(); + e.stopImmediatePropagation(); + alertMovedFromServiceDeskWarning.remove(); + localStorage.setItem(this.alertMovedFromServiceDeskDismissedKey, true); + }); + } + static submitNoteForm(form) { const noteText = form.find('textarea.js-note-text').val(); if (noteText && noteText.trim().length > 0) { diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index be796e80ba9..a9b8b1db922 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -218,13 +218,16 @@ export const fetchPrometheusMetric = ( { commit, state, getters }, { metric, defaultQueryParams }, ) => { - const queryParams = { ...defaultQueryParams }; + let queryParams = { ...defaultQueryParams }; if (metric.step) { queryParams.step = metric.step; } if (Object.keys(state.variables).length > 0) { - queryParams.variables = getters.getCustomVariablesArray; + queryParams = { + ...queryParams, + ...getters.getCustomVariablesParams, + }; } commit(types.REQUEST_METRIC_RESULT, { metricId: metric.metricId }); diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js index f3b1e5a7dde..2c721e8d5a2 100644 --- a/app/assets/javascripts/monitoring/stores/getters.js +++ b/app/assets/javascripts/monitoring/stores/getters.js @@ -1,5 +1,5 @@ -import { flatMap } from 'lodash'; import { NOT_IN_DB_PREFIX } from '../constants'; +import { addPrefixToCustomVariableParams } from './utils'; const metricsIdsInPanel = panel => panel.metrics.filter(metric => metric.metricId && metric.result).map(metric => metric.metricId); @@ -116,13 +116,27 @@ export const filteredEnvironments = state => * Maps an variables object to an array along with stripping * the variable prefix. * + * This method outputs an object in the below format + * + * { + * variables[key1]=value1, + * variables[key2]=value2, + * } + * + * This is done so that the backend can identify the custom + * user-defined variables coming through the URL and differentiate + * from other variables used for Prometheus API endpoint. + * * @param {Object} variables - Custom variables provided by the user * @returns {Array} The custom variables array to be send to the API - * in the format of [variable1, variable1_value] + * in the format of {variables[key1]=value1, variables[key2]=value2} */ -export const getCustomVariablesArray = state => - flatMap(state.variables, (variable, key) => [key, variable.value]); +export const getCustomVariablesParams = state => + Object.keys(state.variables).reduce((acc, variable) => { + acc[addPrefixToCustomVariableParams(variable)] = state.variables[variable]?.value; + return acc; + }, {}); // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index 67a8a46a098..060b2e7013e 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -249,3 +249,19 @@ export const normalizeQueryResult = timeSeries => { return normalizedResult; }; + +/** + * Custom variables defined in the dashboard yml file are + * eventually passed over the wire to the backend Prometheus + * API proxy. + * + * This method adds a prefix to the URL param keys so that + * the backend can differential these variables from the other + * variables. + * + * This is currently only used by getters/getCustomVariablesParams + * + * @param {String} key Variable key that needs to be prefixed + * @returns {String} + */ +export const addPrefixToCustomVariableParams = key => `variables[${key}]`; diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index a070cf8866a..9b911f99c83 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -412,7 +412,7 @@ js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input" </gl-alert> <div class="note-form-actions"> <div - class="float-left btn-group + class="btn-group append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" > <button diff --git a/app/assets/javascripts/registry/explorer/components/quickstart_dropdown.vue b/app/assets/javascripts/registry/explorer/components/cli_commands.vue index 96455496239..96455496239 100644 --- a/app/assets/javascripts/registry/explorer/components/quickstart_dropdown.vue +++ b/app/assets/javascripts/registry/explorer/components/cli_commands.vue diff --git a/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue b/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue deleted file mode 100644 index 88a0710574f..00000000000 --- a/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue +++ /dev/null @@ -1,68 +0,0 @@ -<script> -import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; -import { mapState } from 'vuex'; -import { approximateDuration, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility'; -import { - EXPIRATION_POLICY_ALERT_TITLE, - EXPIRATION_POLICY_ALERT_PRIMARY_BUTTON, - EXPIRATION_POLICY_ALERT_FULL_MESSAGE, - EXPIRATION_POLICY_ALERT_SHORT_MESSAGE, -} from '../constants'; - -export default { - components: { - GlAlert, - GlSprintf, - GlLink, - }, - - computed: { - ...mapState(['config', 'images', 'isLoading']), - isEmpty() { - return !this.images || this.images.length === 0; - }, - showAlert() { - return this.config.expirationPolicy?.enabled; - }, - timeTillRun() { - const difference = calculateRemainingMilliseconds(this.config.expirationPolicy?.next_run_at); - return approximateDuration(difference / 1000); - }, - alertConfiguration() { - if (this.isEmpty || this.isLoading) { - return { - title: null, - primaryButton: null, - message: EXPIRATION_POLICY_ALERT_SHORT_MESSAGE, - }; - } - return { - title: EXPIRATION_POLICY_ALERT_TITLE, - primaryButton: EXPIRATION_POLICY_ALERT_PRIMARY_BUTTON, - message: EXPIRATION_POLICY_ALERT_FULL_MESSAGE, - }; - }, - }, -}; -</script> - -<template> - <gl-alert - v-if="showAlert" - :dismissible="false" - :primary-button-text="alertConfiguration.primaryButton" - :primary-button-link="config.settingsPath" - :title="alertConfiguration.title" - > - <gl-sprintf :message="alertConfiguration.message"> - <template #days> - <strong>{{ timeTillRun }}</strong> - </template> - <template #link="{content}"> - <gl-link :href="config.expirationPolicyHelpPagePath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </gl-alert> -</template> diff --git a/app/assets/javascripts/registry/explorer/components/registry_header.vue b/app/assets/javascripts/registry/explorer/components/registry_header.vue new file mode 100644 index 00000000000..6b6154e87dc --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/registry_header.vue @@ -0,0 +1,138 @@ +<script> +import { GlSprintf, GlLink, GlIcon } from '@gitlab/ui'; +import { n__ } from '~/locale'; +import { approximateDuration, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility'; + +import { + CONTAINER_REGISTRY_TITLE, + LIST_INTRO_TEXT, + EXPIRATION_POLICY_WILL_RUN_IN, + EXPIRATION_POLICY_DISABLED_TEXT, + EXPIRATION_POLICY_DISABLED_MESSAGE, +} from '../constants'; + +export default { + components: { + GlIcon, + GlSprintf, + GlLink, + }, + props: { + expirationPolicy: { + type: Object, + default: () => ({}), + required: false, + }, + imagesCount: { + type: Number, + default: 0, + required: false, + }, + helpPagePath: { + type: String, + default: '', + required: false, + }, + expirationPolicyHelpPagePath: { + type: String, + default: '', + required: false, + }, + hideExpirationPolicyData: { + type: Boolean, + required: false, + default: false, + }, + }, + loader: { + repeat: 10, + width: 1000, + height: 40, + }, + i18n: { + CONTAINER_REGISTRY_TITLE, + LIST_INTRO_TEXT, + EXPIRATION_POLICY_DISABLED_MESSAGE, + }, + computed: { + imagesCountText() { + return n__( + 'ContainerRegistry|%{count} Image repository', + 'ContainerRegistry|%{count} Image repositories', + this.imagesCount, + ); + }, + timeTillRun() { + const difference = calculateRemainingMilliseconds(this.expirationPolicy?.next_run_at); + return approximateDuration(difference / 1000); + }, + expirationPolicyEnabled() { + return this.expirationPolicy?.enabled; + }, + expirationPolicyText() { + return this.expirationPolicyEnabled + ? EXPIRATION_POLICY_WILL_RUN_IN + : EXPIRATION_POLICY_DISABLED_TEXT; + }, + showExpirationPolicyTip() { + return ( + !this.expirationPolicyEnabled && this.imagesCount > 0 && !this.hideExpirationPolicyData + ); + }, + }, +}; +</script> + +<template> + <div> + <div + class="gl-display-flex gl-justify-content-space-between gl-align-items-center" + data-testid="header" + > + <h4 data-testid="title">{{ $options.i18n.CONTAINER_REGISTRY_TITLE }}</h4> + <div class="gl-display-none d-sm-block" data-testid="commands-slot"> + <slot name="commands"></slot> + </div> + </div> + <div + v-if="imagesCount" + class="gl-display-flex gl-align-items-center gl-mt-1 gl-mb-3 gl-text-gray-700" + data-testid="subheader" + > + <span class="gl-mr-3" data-testid="images-count"> + <gl-icon class="gl-mr-1" name="container-image" /> + <gl-sprintf :message="imagesCountText"> + <template #count> + {{ imagesCount }} + </template> + </gl-sprintf> + </span> + <span v-if="!hideExpirationPolicyData" data-testid="expiration-policy"> + <gl-icon class="gl-mr-1" name="expire" /> + <gl-sprintf :message="expirationPolicyText"> + <template #time> + {{ timeTillRun }} + </template> + </gl-sprintf> + </span> + </div> + <div data-testid="info-area"> + <p> + <span data-testid="default-intro"> + <gl-sprintf :message="$options.i18n.LIST_INTRO_TEXT"> + <template #docLink="{content}"> + <gl-link :href="helpPagePath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> + <span v-if="showExpirationPolicyTip" data-testid="expiration-disabled-message"> + <gl-sprintf :message="$options.i18n.EXPIRATION_POLICY_DISABLED_MESSAGE"> + <template #docLink="{content}"> + <gl-link :href="expirationPolicyHelpPagePath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> + </p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/registry/explorer/constants.js b/app/assets/javascripts/registry/explorer/constants.js index 7cbe657bfc0..5325086b773 100644 --- a/app/assets/javascripts/registry/explorer/constants.js +++ b/app/assets/javascripts/registry/explorer/constants.js @@ -5,10 +5,10 @@ import { s__ } from '~/locale'; export const CONTAINER_REGISTRY_TITLE = s__('ContainerRegistry|Container Registry'); export const CONNECTION_ERROR_TITLE = s__('ContainerRegistry|Docker connection error'); export const CONNECTION_ERROR_MESSAGE = s__( - `ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}`, + `ContainerRegistry|We are having trouble connecting to the Registry, which could be due to an issue with your project name or path. %{docLinkStart}More information%{docLinkEnd}`, ); export const LIST_INTRO_TEXT = s__( - `ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`, + `ContainerRegistry|With the GitLab Container Registry, every project can have its own space to store images. %{docLinkStart}More information%{docLinkEnd}`, ); export const LIST_DELETE_BUTTON_DISABLED = s__( @@ -103,20 +103,21 @@ export const ADMIN_GARBAGE_COLLECTION_TIP = s__( // Expiration policies -export const EXPIRATION_POLICY_ALERT_TITLE = s__( - 'ContainerRegistry|Retention policy has been Enabled', +export const EXPIRATION_POLICY_WILL_RUN_IN = s__( + 'ContainerRegistry|Expiration policy will run in %{time}', ); -export const EXPIRATION_POLICY_ALERT_PRIMARY_BUTTON = s__('ContainerRegistry|Edit Settings'); -export const EXPIRATION_POLICY_ALERT_FULL_MESSAGE = s__( - 'ContainerRegistry|The retention and expiration policy for this Container Registry has been enabled and will run in %{days}. For more information visit the %{linkStart}documentation%{linkEnd}', + +export const EXPIRATION_POLICY_DISABLED_TEXT = s__( + 'ContainerRegistry|Expiration policy is disabled', ); -export const EXPIRATION_POLICY_ALERT_SHORT_MESSAGE = s__( - 'ContainerRegistry|The retention and expiration policy for this Container Registry has been enabled. For more information visit the %{linkStart}documentation%{linkEnd}', + +export const EXPIRATION_POLICY_DISABLED_MESSAGE = s__( + 'ContainerRegistry|Expiration policies help manage the storage space used by the Container Registry, but the expiration policies for this registry are disabled. Contact your administrator to enable. %{docLinkStart}More information%{docLinkEnd}', ); // Quick Start -export const QUICK_START = s__('ContainerRegistry|Quick Start'); +export const QUICK_START = s__('ContainerRegistry|CLI Commands'); export const LOGIN_COMMAND_LABEL = s__('ContainerRegistry|Login'); export const COPY_LOGIN_TITLE = s__('ContainerRegistry|Copy login command'); export const BUILD_COMMAND_LABEL = s__('ContainerRegistry|Build an image'); diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue index 4efa6f08d84..fa8b56eb76b 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -14,17 +14,15 @@ import Tracking from '~/tracking'; import ProjectEmptyState from '../components/project_empty_state.vue'; import GroupEmptyState from '../components/group_empty_state.vue'; -import ProjectPolicyAlert from '../components/project_policy_alert.vue'; -import QuickstartDropdown from '../components/quickstart_dropdown.vue'; +import RegistryHeader from '../components/registry_header.vue'; import ImageList from '../components/image_list.vue'; +import CliCommands from '../components/cli_commands.vue'; import { DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE, - CONTAINER_REGISTRY_TITLE, CONNECTION_ERROR_TITLE, CONNECTION_ERROR_MESSAGE, - LIST_INTRO_TEXT, REMOVE_REPOSITORY_MODAL_TEXT, REMOVE_REPOSITORY_LABEL, SEARCH_PLACEHOLDER_TEXT, @@ -39,8 +37,6 @@ export default { GlEmptyState, ProjectEmptyState, GroupEmptyState, - ProjectPolicyAlert, - QuickstartDropdown, ImageList, GlModal, GlSprintf, @@ -48,6 +44,8 @@ export default { GlAlert, GlSkeletonLoader, GlSearchBoxByClick, + RegistryHeader, + CliCommands, }, directives: { GlTooltip: GlTooltipDirective, @@ -59,10 +57,8 @@ export default { height: 40, }, i18n: { - CONTAINER_REGISTRY_TITLE, CONNECTION_ERROR_TITLE, CONNECTION_ERROR_MESSAGE, - LIST_INTRO_TEXT, REMOVE_REPOSITORY_MODAL_TEXT, REMOVE_REPOSITORY_LABEL, SEARCH_PLACEHOLDER_TEXT, @@ -85,7 +81,7 @@ export default { label: 'registry_repository_delete', }; }, - showQuickStartDropdown() { + showCommands() { return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length); }, showDeleteAlert() { @@ -149,8 +145,6 @@ export default { </gl-sprintf> </gl-alert> - <project-policy-alert v-if="!config.isGroupPage" class="mt-2" /> - <gl-empty-state v-if="config.characterError" :title="$options.i18n.CONNECTION_ERROR_TITLE" @@ -170,21 +164,17 @@ export default { </gl-empty-state> <template v-else> - <div> - <div class="d-flex justify-content-between align-items-center"> - <h4>{{ $options.i18n.CONTAINER_REGISTRY_TITLE }}</h4> - <quickstart-dropdown v-if="showQuickStartDropdown" class="d-none d-sm-block" /> - </div> - <p> - <gl-sprintf :message="$options.i18n.LIST_INTRO_TEXT"> - <template #docLink="{content}"> - <gl-link :href="config.helpPagePath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </p> - </div> + <registry-header + :images-count="pagination.total" + :expiration-policy="config.expirationPolicy" + :help-page-path="config.helpPagePath" + :expiration-policy-help-page-path="config.expirationPolicyHelpPagePath" + :hide-expiration-policy-data="config.isGroupPage" + > + <template #commands> + <cli-commands v-if="showCommands" /> + </template> + </registry-header> <div v-if="isLoading" class="mt-2"> <gl-skeleton-loader @@ -201,7 +191,7 @@ export default { </div> <template v-else> <template v-if="!isEmpty"> - <div class="gl-display-flex gl-p-1" data-testid="listHeader"> + <div class="gl-display-flex gl-p-1 gl-mt-3" data-testid="listHeader"> <div class="gl-flex-fill-1"> <h5>{{ $options.i18n.IMAGE_REPOSITORY_LIST_LABEL }}</h5> </div> diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue index dff21d919a9..fa142385f06 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_area.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue @@ -47,7 +47,7 @@ export default { }; </script> <template> - <div class="d-flex flex-grow-1 flex-column"> + <div class="d-flex flex-grow-1 flex-column h-100"> <edit-header class="py-2" :title="title" /> <rich-content-editor v-model="editableContent" class="mb-9" /> <publish-toolbar 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 457f1806452..0baef05edbc 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 @@ -1,5 +1,9 @@ import { __ } from '~/locale'; -import { generateToolbarItem } from './toolbar_service'; +import { generateToolbarItem } from './editor_service'; + +export const CUSTOM_EVENTS = { + openAddImageModal: 'gl_openAddImageModal', +}; /* eslint-disable @gitlab/require-i18n-strings */ const TOOLBAR_ITEM_CONFIGS = [ diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js index fff90f3e3fb..943e7add969 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js @@ -12,7 +12,6 @@ const buildWrapper = propsData => { return instance.$el; }; -// eslint-disable-next-line import/prefer-default-export export const generateToolbarItem = config => { const { icon, classes, event, command, tooltip, isDivider } = config; @@ -30,3 +29,8 @@ export const generateToolbarItem = config => { }, }; }; + +export const addCustomEventListener = (editorInstance, event, handler) => { + editorInstance.eventManager.addEventType(event); + editorInstance.eventManager.listen(event, handler); +}; 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 ba3696c8ad1..0618fcbb3bb 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 @@ -2,7 +2,15 @@ import 'codemirror/lib/codemirror.css'; import '@toast-ui/editor/dist/toastui-editor.css'; -import { EDITOR_OPTIONS, EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE } from './constants'; +import { + EDITOR_OPTIONS, + EDITOR_TYPES, + EDITOR_HEIGHT, + EDITOR_PREVIEW_STYLE, + CUSTOM_EVENTS, +} from './constants'; + +import { addCustomEventListener } from './editor_service'; export default { components: { @@ -49,6 +57,16 @@ export default { getMarkdown() { return this.$refs.editor.invoke('getMarkdown'); }, + onLoad(editorInstance) { + addCustomEventListener( + editorInstance, + CUSTOM_EVENTS.openAddImageModal, + this.onOpenAddImageModal, + ); + }, + onOpenAddImageModal() { + // TODO - add image modal (next MR) + }, }, }; </script> @@ -61,5 +79,6 @@ export default { :initial-edit-type="initialEditType" :height="height" @change="onContentChanged" + @load="onLoad" /> </template> diff --git a/app/assets/stylesheets/pages/storage_quota.scss b/app/assets/stylesheets/pages/storage_quota.scss index 97ae4f0ade4..347bd1316c0 100644 --- a/app/assets/stylesheets/pages/storage_quota.scss +++ b/app/assets/stylesheets/pages/storage_quota.scss @@ -9,14 +9,8 @@ @include gl-rounded-bottom-right-base; } - &:not(:first-child) { - @include gl-border-l-1; - @include gl-border-l-solid; - @include gl-border-white; - } - &:not(:last-child) { - @include gl-border-r-1; + @include gl-border-r-2; @include gl-border-r-solid; @include gl-border-white; } diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index aa39d430b24..46dec5f3287 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -23,6 +23,7 @@ class Clusters::ClustersController < Clusters::BaseController respond_to do |format| format.html format.json do + Gitlab::PollingInterval.set_header(response, interval: STATUS_POLLING_INTERVAL) serializer = ClusterSerializer.new(current_user: current_user) render json: { diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 5aa00af8910..9ef067e8797 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -5,7 +5,6 @@ module IssuableCollections include PaginatedCollection include SortingHelper include SortingPreference - include Gitlab::IssuableMetadata include Gitlab::Utils::StrongMemoize included do @@ -44,7 +43,7 @@ module IssuableCollections def set_pagination @issuables = @issuables.page(params[:page]) @issuables = per_page_for_relative_position if params[:sort] == 'relative_position' - @issuable_meta_data = issuable_meta_data(@issuables, collection_type, current_user) + @issuable_meta_data = Gitlab::IssuableMetadata.new(current_user, @issuables).data @total_pages = issuable_page_count(@issuables) end # rubocop:enable Gitlab/ModuleWithInstanceVariables diff --git a/app/controllers/concerns/issuable_collections_action.rb b/app/controllers/concerns/issuable_collections_action.rb index 78b3c6771b3..e3ac117660b 100644 --- a/app/controllers/concerns/issuable_collections_action.rb +++ b/app/controllers/concerns/issuable_collections_action.rb @@ -11,7 +11,7 @@ module IssuableCollectionsAction .non_archived .page(params[:page]) - @issuable_meta_data = issuable_meta_data(@issues, collection_type, current_user) + @issuable_meta_data = Gitlab::IssuableMetadata.new(current_user, @issues).data respond_to do |format| format.html @@ -22,7 +22,7 @@ module IssuableCollectionsAction def merge_requests @merge_requests = issuables_collection.page(params[:page]) - @issuable_meta_data = issuable_meta_data(@merge_requests, collection_type, current_user) + @issuable_meta_data = Gitlab::IssuableMetadata.new(current_user, @merge_requests).data end # rubocop:enable Gitlab/ModuleWithInstanceVariables diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index ff292973546..3597a0f307a 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -314,8 +314,7 @@ class ProjectsController < Projects::ApplicationController @wiki_home = @project_wiki.find_page('home', params[:version_id]) elsif @project.feature_available?(:issues, current_user) @issues = issuables_collection.page(params[:page]) - @collection_type = 'Issue' - @issuable_meta_data = issuable_meta_data(@issues, @collection_type, current_user) + @issuable_meta_data = Gitlab::IssuableMetadata.new(current_user, @issues).data end render :show diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index ffbccbb01f2..c7da029d2f3 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -14,6 +14,7 @@ class RegistrationsController < Devise::RegistrationsController before_action :ensure_terms_accepted, if: -> { action_name == 'create' && Gitlab::CurrentSettings.current_application_settings.enforce_terms? } before_action :load_recaptcha, only: :new + before_action :authenticate_user!, only: :experience_level def new if experiment_enabled?(:signup_flow) @@ -57,6 +58,10 @@ class RegistrationsController < Devise::RegistrationsController return redirect_to path_for_signed_in_user(current_user) if current_user.role.present? && !current_user.setup_for_company.nil? end + def experience_level + return access_denied! unless experiment_enabled?(:onboarding_issues) + end + def update_registration user_params = params.require(:user).permit(:role, :setup_for_company) result = ::Users::SignupService.new(current_user, user_params).execute diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb index 6fc223fbee7..b5ee38857fb 100644 --- a/app/graphql/mutations/snippets/create.rb +++ b/app/graphql/mutations/snippets/create.rb @@ -60,7 +60,7 @@ module Mutations snippet = service_response.payload[:snippet] { - snippet: snippet.valid? ? snippet : nil, + snippet: service_response.success? ? snippet : nil, errors: errors_on_object(snippet) } end diff --git a/app/helpers/milestones_helper.rb b/app/helpers/timeboxes_helper.rb index df1ee54c5ac..87ea22d8f83 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/timeboxes_helper.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module MilestonesHelper +module TimeboxesHelper include EntityDateHelper include Gitlab::Utils::StrongMemoize @@ -209,23 +209,24 @@ module MilestonesHelper end end - def milestone_date_range(milestone) - if milestone.start_date && milestone.due_date - "#{milestone.start_date.to_s(:medium)}–#{milestone.due_date.to_s(:medium)}" - elsif milestone.due_date - if milestone.due_date.past? - _("expired on %{milestone_due_date}") % { milestone_due_date: milestone.due_date.strftime('%b %-d, %Y') } + def timebox_date_range(timebox) + if timebox.start_date && timebox.due_date + "#{timebox.start_date.to_s(:medium)}–#{timebox.due_date.to_s(:medium)}" + elsif timebox.due_date + if timebox.due_date.past? + _("expired on %{timebox_due_date}") % { timebox_due_date: timebox.due_date.to_s(:medium) } else - _("expires on %{milestone_due_date}") % { milestone_due_date: milestone.due_date.strftime('%b %-d, %Y') } + _("expires on %{timebox_due_date}") % { timebox_due_date: timebox.due_date.to_s(:medium) } end - elsif milestone.start_date - if milestone.start_date.past? - _("started on %{milestone_start_date}") % { milestone_start_date: milestone.start_date.strftime('%b %-d, %Y') } + elsif timebox.start_date + if timebox.start_date.past? + _("started on %{timebox_start_date}") % { timebox_start_date: timebox.start_date.to_s(:medium) } else - _("starts on %{milestone_start_date}") % { milestone_start_date: milestone.start_date.strftime('%b %-d, %Y') } + _("starts on %{timebox_start_date}") % { timebox_start_date: timebox.start_date.to_s(:medium) } end end end + alias_method :milestone_date_range, :timebox_date_range def milestone_tab_path(milestone, tab) if milestone.global_milestone? @@ -306,4 +307,4 @@ module MilestonesHelper end end -MilestonesHelper.prepend_if_ee('EE::MilestonesHelper') +TimeboxesHelper.prepend_if_ee('EE::TimeboxesHelper') diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index d4d93ab9795..bcf60bea0e0 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -126,3 +126,5 @@ module Emails end end end + +Emails::Issues.prepend_if_ee('EE::Emails::Issues') diff --git a/app/mailers/emails/reviews.rb b/app/mailers/emails/reviews.rb new file mode 100644 index 00000000000..ddb9e161a80 --- /dev/null +++ b/app/mailers/emails/reviews.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Emails + module Reviews + def new_review_email(recipient_id, review_id) + setup_review_email(review_id, recipient_id) + + mail_answer_thread(@merge_request, review_thread_options(recipient_id)) + end + + private + + def review_thread_options(recipient_id) + { + from: sender(@author.id), + to: User.find(recipient_id).notification_email_for(@merge_request.target_project.group), + subject: subject("#{@merge_request.title} (#{@merge_request.to_reference})") + } + end + + def setup_review_email(review_id, recipient_id) + review = Review.find_by_id(review_id) + + @notes = review.notes + @author = review.author + @author_name = review.author_name + @project = review.project + @merge_request = review.merge_request + @target_url = project_merge_request_url(@project, @merge_request) + @sent_notification = SentNotification.record(@merge_request, recipient_id, reply_key) + end + end +end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index d9483bab543..2cf72d40635 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -18,8 +18,9 @@ class Notify < ApplicationMailer include Emails::RemoteMirrors include Emails::Releases include Emails::Groups + include Emails::Reviews - helper MilestonesHelper + helper TimeboxesHelper helper MergeRequestsHelper helper DiffHelper helper BlobHelper diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 30ea383bd73..6f12ec921d4 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -23,10 +23,17 @@ module Ci project_type: 3 } - ONLINE_CONTACT_TIMEOUT = 1.hour + # This `ONLINE_CONTACT_TIMEOUT` needs to be larger than + # `RUNNER_QUEUE_EXPIRY_TIME+UPDATE_CONTACT_COLUMN_EVERY` + # + ONLINE_CONTACT_TIMEOUT = 2.hours + + # The `RUNNER_QUEUE_EXPIRY_TIME` indicates the longest interval that + # Runner request needs to be refreshed by Rails instead of being handled + # by Workhorse RUNNER_QUEUE_EXPIRY_TIME = 1.hour - # This needs to be less than `ONLINE_CONTACT_TIMEOUT` + # The `UPDATE_CONTACT_COLUMN_EVERY` defines how often the Runner DB entry can be updated UPDATE_CONTACT_COLUMN_EVERY = (40.minutes..55.minutes).freeze AVAILABLE_TYPES_LEGACY = %w[specific shared].freeze @@ -282,7 +289,7 @@ module Ci ensure_runner_queue_value == value if value.present? end - def update_cached_info(values) + def heartbeat(values) values = values&.slice(:version, :revision, :platform, :architecture, :ip_address) || {} values[:contacted_at] = Time.current diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 5622a53bb5d..b202a6579e7 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -189,8 +189,10 @@ class CommitStatus < ApplicationRecord end def self.update_as_processed! - # Marks items as processed, and increases `lock_version` (Optimisitc Locking) - update_all('processed=TRUE, lock_version=COALESCE(lock_version,0)+1') + # Marks items as processed + # we do not increase `lock_version`, as we are the one + # holding given lock_version (Optimisitc Locking) + update_all(processed: true) end def self.locking_enabled? diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index f011426c9db..92d89ad9092 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -39,15 +39,6 @@ module Issuable locked: 4 }.with_indifferent_access.freeze - # This object is used to gather issuable meta data for displaying - # upvotes, downvotes, notes and closing merge requests count for issues and merge requests - # lists avoiding n+1 queries and improving performance. - IssuableMeta = Struct.new(:upvotes, :downvotes, :user_notes_count, :mrs_count) do - def merge_requests_count(user = nil) - mrs_count - end - end - included do cache_markdown_field :title, pipeline: :single_line cache_markdown_field :description, issuable_state_filter_enabled: true diff --git a/app/models/concerns/limitable.rb b/app/models/concerns/limitable.rb index f320f54bb82..3cb0bd85936 100644 --- a/app/models/concerns/limitable.rb +++ b/app/models/concerns/limitable.rb @@ -2,6 +2,7 @@ module Limitable extend ActiveSupport::Concern + GLOBAL_SCOPE = :limitable_global_scope included do class_attribute :limit_scope @@ -14,14 +15,34 @@ module Limitable private def validate_plan_limit_not_exceeded + if GLOBAL_SCOPE == limit_scope + validate_global_plan_limit_not_exceeded + else + validate_scoped_plan_limit_not_exceeded + end + end + + def validate_scoped_plan_limit_not_exceeded scope_relation = self.public_send(limit_scope) # rubocop:disable GitlabSecurity/PublicSend return unless scope_relation relation = self.class.where(limit_scope => scope_relation) + limits = scope_relation.actual_limits - if scope_relation.actual_limits.exceeded?(limit_name, relation) - errors.add(:base, _("Maximum number of %{name} (%{count}) exceeded") % - { name: limit_name.humanize(capitalize: false), count: scope_relation.actual_limits.public_send(limit_name) }) # rubocop:disable GitlabSecurity/PublicSend - end + check_plan_limit_not_exceeded(limits, relation) + end + + def validate_global_plan_limit_not_exceeded + relation = self.class.all + limits = Plan.default.actual_limits + + check_plan_limit_not_exceeded(limits, relation) + end + + def check_plan_limit_not_exceeded(limits, relation) + return unless limits.exceeded?(limit_name, relation) + + errors.add(:base, _("Maximum number of %{name} (%{count}) exceeded") % + { name: limit_name.humanize(capitalize: false), count: limits.public_send(limit_name) }) # rubocop:disable GitlabSecurity/PublicSend end end diff --git a/app/services/draft_notes/base_service.rb b/app/services/draft_notes/base_service.rb new file mode 100644 index 00000000000..89daae0e8f4 --- /dev/null +++ b/app/services/draft_notes/base_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module DraftNotes + class BaseService < ::BaseService + attr_accessor :merge_request, :current_user, :params + + def initialize(merge_request, current_user, params = nil) + @merge_request, @current_user, @params = merge_request, current_user, params.dup + end + + private + + def draft_notes + @draft_notes ||= merge_request.draft_notes.order_id_asc.authored_by(current_user) + end + + def project + merge_request.target_project + end + end +end diff --git a/app/services/draft_notes/create_service.rb b/app/services/draft_notes/create_service.rb new file mode 100644 index 00000000000..501778b7d5f --- /dev/null +++ b/app/services/draft_notes/create_service.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module DraftNotes + class CreateService < DraftNotes::BaseService + attr_accessor :in_draft_mode, :in_reply_to_discussion_id + + def initialize(merge_request, current_user, params = nil) + @in_reply_to_discussion_id = params.delete(:in_reply_to_discussion_id) + super + end + + def execute + if in_reply_to_discussion_id.present? + unless discussion + return base_error(_('Thread to reply to cannot be found')) + end + + params[:discussion_id] = discussion.reply_id + end + + if params[:resolve_discussion] && !can_resolve_discussion? + return base_error(_('User is not allowed to resolve thread')) + end + + draft_note = DraftNote.new(params) + draft_note.merge_request = merge_request + draft_note.author = current_user + draft_note.save + + if in_reply_to_discussion_id.blank? && draft_note.diff_file&.unfolded? + merge_request.diffs.clear_cache + end + + draft_note + end + + private + + def base_error(text) + DraftNote.new.tap do |draft| + draft.errors.add(:base, text) + end + end + + def discussion + @discussion ||= merge_request.notes.find_discussion(in_reply_to_discussion_id) + end + + def can_resolve_discussion? + note = discussion&.notes&.first + return false unless note + + current_user && Ability.allowed?(current_user, :resolve_note, note) + end + end +end diff --git a/app/services/draft_notes/destroy_service.rb b/app/services/draft_notes/destroy_service.rb new file mode 100644 index 00000000000..ddca0debb03 --- /dev/null +++ b/app/services/draft_notes/destroy_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module DraftNotes + class DestroyService < DraftNotes::BaseService + # If no `draft` is given it fallsback to all + # draft notes of the given merge request and user. + def execute(draft = nil) + drafts = draft || draft_notes + + clear_highlight_diffs_cache(Array.wrap(drafts)) + + drafts.is_a?(DraftNote) ? drafts.destroy! : drafts.delete_all + end + + private + + def clear_highlight_diffs_cache(drafts) + if drafts.any? { |draft| draft.diff_file&.unfolded? } + merge_request.diffs.clear_cache + end + end + end +end diff --git a/app/services/draft_notes/publish_service.rb b/app/services/draft_notes/publish_service.rb new file mode 100644 index 00000000000..a9a7304e5ed --- /dev/null +++ b/app/services/draft_notes/publish_service.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module DraftNotes + class PublishService < DraftNotes::BaseService + def execute(draft = nil) + return error('Not allowed to create notes') unless can?(current_user, :create_note, merge_request) + + if draft + publish_draft_note(draft) + else + publish_draft_notes + end + + success + rescue ActiveRecord::RecordInvalid => e + message = "Unable to save #{e.record.class.name}: #{e.record.errors.full_messages.join(", ")} " + error(message) + end + + private + + def publish_draft_note(draft) + create_note_from_draft(draft) + draft.delete + + MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(merge_request) + end + + def publish_draft_notes + return if draft_notes.empty? + + review = Review.create!(author: current_user, merge_request: merge_request, project: project) + + draft_notes.map do |draft_note| + draft_note.review = review + create_note_from_draft(draft_note) + end + draft_notes.delete_all + + notification_service.async.new_review(review) + MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(merge_request) + end + + def create_note_from_draft(draft) + # Make sure the diff file is unfolded in order to find the correct line + # codes. + draft.diff_file&.unfold_diff_lines(draft.original_position) + + note = Notes::CreateService.new(draft.project, draft.author, draft.publish_params).execute + set_discussion_resolve_status(note, draft) + + note + end + + def set_discussion_resolve_status(note, draft_note) + return unless draft_note.discussion_id.present? + + discussion = note.discussion + + if draft_note.resolve_discussion && discussion.can_resolve?(current_user) + discussion.resolve!(current_user) + else + discussion.unresolve! + end + end + end +end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 6c1f52ec866..935dbfb72dd 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -88,9 +88,11 @@ module Notes end end - # EE::Notes::CreateService would override this method def quick_action_options - { merge_request_diff_head_sha: params[:merge_request_diff_head_sha] } + { + merge_request_diff_head_sha: params[:merge_request_diff_head_sha], + review_id: params[:review_id] + } end def tracking_data_for(note) @@ -103,5 +105,3 @@ module Notes end end end - -Notes::CreateService.prepend_if_ee('EE::Notes::CreateService') diff --git a/app/services/notification_recipients/build_service.rb b/app/services/notification_recipients/build_service.rb index df807f11e1b..0fe0d26d7b2 100644 --- a/app/services/notification_recipients/build_service.rb +++ b/app/services/notification_recipients/build_service.rb @@ -32,7 +32,9 @@ module NotificationRecipients def self.build_new_release_recipients(*args) ::NotificationRecipients::Builder::NewRelease.new(*args).notification_recipients end + + def self.build_new_review_recipients(*args) + ::NotificationRecipients::Builder::NewReview.new(*args).notification_recipients + end end end - -NotificationRecipients::BuildService.prepend_if_ee('EE::NotificationRecipients::BuildService') diff --git a/app/services/notification_recipients/builder/new_review.rb b/app/services/notification_recipients/builder/new_review.rb new file mode 100644 index 00000000000..3b1296f6967 --- /dev/null +++ b/app/services/notification_recipients/builder/new_review.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module NotificationRecipients + module Builder + class NewReview < Base + attr_reader :review + def initialize(review) + @review = review + end + + def target + review.merge_request + end + + def project + review.project + end + + def group + project.group + end + + def build! + add_participants(review.author) + add_mentions(review.author, target: review) + add_project_watchers + add_custom_notifications + add_subscribed_users + end + + # A new review is a batch of new notes + # therefore new_note subscribers should also + # receive incoming new reviews + def custom_action + :new_note + end + + def acting_user + review.author + end + end + end +end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 4c1db03fab8..ae512563585 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -557,6 +557,15 @@ class NotificationService mailer.group_was_not_exported_email(current_user, group, errors).deliver_later end + # Notify users on new review in system + def new_review(review) + recipients = NotificationRecipients::BuildService.build_new_review_recipients(review) + + recipients.each do |recipient| + mailer.new_review_email(recipient.user.id, review.id).deliver_later + end + end + protected def new_resource_email(target, method) diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index 7bebaca684a..59389a0fa65 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -2,8 +2,6 @@ module Projects class UpdatePagesService < BaseService - include Gitlab::OptimisticLocking - InvalidStateError = Class.new(StandardError) FailedToExtractError = Class.new(StandardError) @@ -25,8 +23,8 @@ module Projects # Create status notifying the deployment of pages @status = create_status - retry_optimistic_lock(@status, &:enqueue!) - retry_optimistic_lock(@status, &:run!) + @status.enqueue! + @status.run! raise InvalidStateError, 'missing pages artifacts' unless build.artifacts? raise InvalidStateError, 'build SHA is outdated for this ref' unless latest? @@ -53,7 +51,7 @@ module Projects private def success - retry_optimistic_lock(@status, &:success) + @status.success @project.mark_pages_as_deployed super end @@ -63,7 +61,7 @@ module Projects log_error("Projects::UpdatePagesService: #{message}") @status.allow_failure = !latest? @status.description = message - retry_optimistic_lock(@status) { |status| status.drop(:script_failure) } + @status.drop(:script_failure) super end diff --git a/app/services/prometheus/proxy_variable_substitution_service.rb b/app/services/prometheus/proxy_variable_substitution_service.rb index aa3a09ba05c..7b98cfc592a 100644 --- a/app/services/prometheus/proxy_variable_substitution_service.rb +++ b/app/services/prometheus/proxy_variable_substitution_service.rb @@ -32,8 +32,8 @@ module Prometheus def validate_variables(_result) return success unless variables - unless variables.is_a?(Array) && variables.size.even? - return error(_('Optional parameter "variables" must be an array of keys and values. Ex: [key1, value1, key2, value2]')) + unless variables.is_a?(ActionController::Parameters) + return error(_('Optional parameter "variables" must be a Hash. Ex: variables[key1]=value1')) end success @@ -88,12 +88,7 @@ module Prometheus end def variables_hash - # .each_slice(2) converts ['key1', 'value1', 'key2', 'value2'] into - # [['key1', 'value1'], ['key2', 'value2']] which is then converted into - # a hash by to_h: {'key1' => 'value1', 'key2' => 'value2'} - # to_h will raise an ArgumentError if the number of elements in the original - # array is not even. - variables&.each_slice(2).to_h + variables.to_h end def query(result) diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb index 5ca9ed67e56..1b46edd4d7d 100644 --- a/app/services/users/migrate_to_ghost_user_service.rb +++ b/app/services/users/migrate_to_ghost_user_service.rb @@ -53,6 +53,7 @@ module Users migrate_abuse_reports migrate_award_emoji migrate_snippets + migrate_reviews end # rubocop: disable CodeReuse/ActiveRecord @@ -85,6 +86,10 @@ module Users snippets = user.snippets.only_project_snippets snippets.update_all(author_id: ghost_user.id) end + + def migrate_reviews + user.reviews.update_all(author_id: ghost_user.id) + end end end diff --git a/app/views/notify/new_review_email.html.haml b/app/views/notify/new_review_email.html.haml new file mode 100644 index 00000000000..ad870473681 --- /dev/null +++ b/app/views/notify/new_review_email.html.haml @@ -0,0 +1,16 @@ +%table{ border: "0", cellpadding:"0", cellspacing: "0", style: "width:100%;margin:0 auto;border-collapse:separate;border-spacing:0;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#ffffff;text-align:left;overflow:hidden;" } + %table{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;border-collapse:separate;border-spacing:0;" } + %tbody + %tr + %td{ style: "color:#333333;border-bottom:1px solid #ededed;font-size:15px;font-weight:bold;line-height:1.4;padding: 20px 0;" } + - mr_link = link_to(@merge_request.to_reference(@project), project_merge_request_url(@project, @merge_request)) + - mr_author_link = link_to(@author_name, user_url(@author)) + = _('Merge request %{mr_link} was reviewed by %{mr_author}').html_safe % { mr_link: mr_link, mr_author: mr_author_link } + %tr + %td{ style: "overflow:hidden;font-size:14px;line-height:1.4;display:grid;" } + - @notes.each do |note| + - target_url = project_merge_request_url(@project, @merge_request, anchor: "note_#{note.id}") + = render 'note_email', note: note, diff_limit: 3, target_url: target_url, note_style: "border-bottom:1px solid #ededed;" diff --git a/app/views/notify/new_review_email.text.erb b/app/views/notify/new_review_email.text.erb new file mode 100644 index 00000000000..164735abad0 --- /dev/null +++ b/app/views/notify/new_review_email.text.erb @@ -0,0 +1,13 @@ +<% mr_url = merge_request_url(@merge_request) %> +<% mr_author_name = sanitize_name(@author_name) %> +<%= _('Merge request %{mr_link} was reviewed by %{mr_author}') % { mr_link: mr_url, mr_author: mr_author_name } %> + +-- +<% @notes.each_with_index do |note, index| %> + <% target_url = project_merge_request_url(@project, @merge_request, anchor: "note_#{note.id}") %> + <%= render 'note_email', note: note, diff_limit: 3, target_url: target_url %> + + <% if index != @notes.length-1 %> +-- + <% end %> +<% end %> diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index c8ffa2e3720..9623b6edbef 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -11,6 +11,7 @@ - can_create_issue = show_new_issue_link?(@project) = render_if_exists "projects/issues/alert_blocked", issue: @issue, current_user: current_user += render_if_exists "projects/issues/alert_moved_from_service_desk", issue: @issue .detail-page-header .detail-page-header-body diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index 650e63eb406..7c3fe9eeca5 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -5,7 +5,6 @@ .row.registry-placeholder.prepend-bottom-10 .col-12 #js-container-registry{ data: { endpoint: project_container_registry_index_path(@project), - settings_path: project_settings_ci_cd_path(@project), expiration_policy: @project.container_expiration_policy.to_json, "help_page_path" => help_page_path('user/packages/container_registry/index'), "two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'), diff --git a/app/views/registrations/experience_level.html.haml b/app/views/registrations/experience_level.html.haml new file mode 100644 index 00000000000..5ceed919c32 --- /dev/null +++ b/app/views/registrations/experience_level.html.haml @@ -0,0 +1,26 @@ +- content_for(:page_title, _('What’s your experience level?')) + +%h3= _('Hello there') +%p= _('Welcome to the guided GitLab tour') + +%br + +%h5= _('What describes you best?') + +%hr + +%div + %p + %b= _('Novice') + %p= _('I’m not very familiar with the basics of project management and DevOps.') + %p + %a{ href: '#novice' }= _('Show me everything') + +%hr + +%div + %p + %b= _('Experienced') + %p= _('I’m familiar with the basics of project management and DevOps.') + %p + %a{ href: '#experienced' }= _('Show me more advanced stuff') diff --git a/babel.config.js b/babel.config.js index 9c419b93b33..6d377305e46 100644 --- a/babel.config.js +++ b/babel.config.js @@ -2,7 +2,7 @@ const BABEL_ENV = process.env.BABEL_ENV || process.env.NODE_ENV || null; -const presets = [ +let presets = [ [ '@babel/preset-env', { @@ -49,6 +49,17 @@ if (isJest) { https://gitlab.com/gitlab-org/gitlab-foss/issues/58390 */ plugins.push('babel-plugin-dynamic-import-node'); + + presets = [ + [ + '@babel/preset-env', + { + targets: { + node: 'current', + }, + }, + ], + ]; } module.exports = { presets, plugins, sourceType: 'unambiguous' }; diff --git a/bin/background_jobs_sk b/bin/background_jobs_sk index fb7de0a6180..0aab69126b2 100755 --- a/bin/background_jobs_sk +++ b/bin/background_jobs_sk @@ -36,7 +36,7 @@ start_silent() start_sidekiq() { cmd="exec" - chpst=$(which chpst) + chpst=$(command -v chpst) if [ -n "$chpst" ]; then cmd="${cmd} ${chpst} -P" diff --git a/bin/background_jobs_sk_cluster b/bin/background_jobs_sk_cluster index b1d5fce204e..6188ec51420 100755 --- a/bin/background_jobs_sk_cluster +++ b/bin/background_jobs_sk_cluster @@ -43,7 +43,7 @@ restart() start_sidekiq() { cmd="exec" - chpst=$(which chpst) + chpst=$(command -v chpst) if [ -n "$chpst" ]; then cmd="${cmd} ${chpst} -P" diff --git a/changelogs/unreleased/196544-nodemetrics-size.yml b/changelogs/unreleased/196544-nodemetrics-size.yml new file mode 100644 index 00000000000..1319ef2f624 --- /dev/null +++ b/changelogs/unreleased/196544-nodemetrics-size.yml @@ -0,0 +1,5 @@ +--- +title: Added node size to cluster index +merge_request: 32435 +author: +type: changed diff --git a/changelogs/unreleased/216749-improve-the-container-registry-ui-header-section-with-relevant-met.yml b/changelogs/unreleased/216749-improve-the-container-registry-ui-header-section-with-relevant-met.yml new file mode 100644 index 00000000000..286453d9575 --- /dev/null +++ b/changelogs/unreleased/216749-improve-the-container-registry-ui-header-section-with-relevant-met.yml @@ -0,0 +1,5 @@ +--- +title: Improve Container Registry UI header +merge_request: 32424 +author: +type: changed diff --git a/changelogs/unreleased/218312-change-variables-parameter-format.yml b/changelogs/unreleased/218312-change-variables-parameter-format.yml new file mode 100644 index 00000000000..352248bbfc5 --- /dev/null +++ b/changelogs/unreleased/218312-change-variables-parameter-format.yml @@ -0,0 +1,5 @@ +--- +title: Change format of variables parameter in Prometheus proxy API for metrics dashboard +merge_request: 33062 +author: +type: fixed diff --git a/changelogs/unreleased/219145-comment-button-does-not-show-up-on-mr-when-comments-are-set-to-new.yml b/changelogs/unreleased/219145-comment-button-does-not-show-up-on-mr-when-comments-are-set-to-new.yml new file mode 100644 index 00000000000..232842c3c5a --- /dev/null +++ b/changelogs/unreleased/219145-comment-button-does-not-show-up-on-mr-when-comments-are-set-to-new.yml @@ -0,0 +1,5 @@ +--- +title: Fix overflow issue in MR and Issue comments +merge_request: 33100 +author: +type: fixed diff --git a/changelogs/unreleased/add-global-plans.yml b/changelogs/unreleased/add-global-plans.yml new file mode 100644 index 00000000000..cafd6cfc227 --- /dev/null +++ b/changelogs/unreleased/add-global-plans.yml @@ -0,0 +1,5 @@ +--- +title: Adapt Limitable for system-wide features +merge_request: 32574 +author: +type: added diff --git a/changelogs/unreleased/fix-atomic-processing-lock-version.yml b/changelogs/unreleased/fix-atomic-processing-lock-version.yml new file mode 100644 index 00000000000..9db891d651f --- /dev/null +++ b/changelogs/unreleased/fix-atomic-processing-lock-version.yml @@ -0,0 +1,5 @@ +--- +title: Fix atomic processing bumping a lock_version +merge_request: 32914 +author: +type: fixed diff --git a/changelogs/unreleased/fix-runner-hearbeat.yml b/changelogs/unreleased/fix-runner-hearbeat.yml new file mode 100644 index 00000000000..8125bad48ab --- /dev/null +++ b/changelogs/unreleased/fix-runner-hearbeat.yml @@ -0,0 +1,5 @@ +--- +title: Fix Runner heartbeats that results in considering them offline +merge_request: 32851 +author: +type: fixed diff --git a/changelogs/unreleased/fj-fix-snippet-create-mutation-non-activerecord-errors.yml b/changelogs/unreleased/fj-fix-snippet-create-mutation-non-activerecord-errors.yml new file mode 100644 index 00000000000..4d5bbceb870 --- /dev/null +++ b/changelogs/unreleased/fj-fix-snippet-create-mutation-non-activerecord-errors.yml @@ -0,0 +1,5 @@ +--- +title: Fix bug in snippet create mutation with non ActiveRecord errors +merge_request: 33085 +author: +type: fixed diff --git a/changelogs/unreleased/iterations_add_daterange_constraint.yml b/changelogs/unreleased/iterations_add_daterange_constraint.yml deleted file mode 100644 index bbc46cbe1e7..00000000000 --- a/changelogs/unreleased/iterations_add_daterange_constraint.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Add btree_gist PGSQL extension and add DB constraints for Iteration date ranges -merge_request: 32335 -author: -type: added diff --git a/changelogs/unreleased/remove-redundant-modsecurity-indexes.yml b/changelogs/unreleased/remove-redundant-modsecurity-indexes.yml new file mode 100644 index 00000000000..41a1759281d --- /dev/null +++ b/changelogs/unreleased/remove-redundant-modsecurity-indexes.yml @@ -0,0 +1,5 @@ +--- +title: Remove unused WAF indexes from CI variables +merge_request: 30021 +author: +type: other diff --git a/changelogs/unreleased/sy-publish-command.yml b/changelogs/unreleased/sy-publish-command.yml new file mode 100644 index 00000000000..cfc46e8f1c1 --- /dev/null +++ b/changelogs/unreleased/sy-publish-command.yml @@ -0,0 +1,6 @@ +--- +title: Backfill StatusPage::Published incidents and enable a publish quick action + for EE +merge_request: 30906 +author: +type: added diff --git a/config/routes.rb b/config/routes.rb index 86f42822299..dfc9be3d4f8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -46,6 +46,7 @@ Rails.application.routes.draw do # Sign up get 'users/sign_up/welcome' => 'registrations#welcome' + get 'users/sign_up/experience_level' => 'registrations#experience_level' patch 'users/sign_up/update_registration' => 'registrations#update_registration' # Search diff --git a/danger/changelog/Dangerfile b/danger/changelog/Dangerfile index 7b8a096639d..aa7a81555a3 100644 --- a/danger/changelog/Dangerfile +++ b/danger/changelog/Dangerfile @@ -3,7 +3,7 @@ require 'yaml' -SEE_DOC = "See [the documentation](https://docs.gitlab.com/ee/development/changelog.html)." +SEE_DOC = "See the [changelog documentation](https://docs.gitlab.com/ee/development/changelog.html)." CREATE_CHANGELOG_MESSAGE = <<~MSG If you want to create a changelog entry for GitLab FOSS, run the following: @@ -20,14 +20,29 @@ bin/changelog --ee -m %<mr_iid>s "%<mr_title>s" If this merge request [doesn't need a CHANGELOG entry](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry), feel free to ignore this message. MSG +SUGGEST_MR_COMMENT = <<~SUGGEST_COMMENT +```suggestion +merge_request: %<mr_iid>s +``` + +#{SEE_DOC} +SUGGEST_COMMENT + def check_changelog_yaml(path) - yaml = YAML.safe_load(File.read(path)) + raw_file = File.read(path) + yaml = YAML.safe_load(raw_file) fail "`title` should be set, in #{gitlab.html_link(path)}! #{SEE_DOC}" if yaml["title"].nil? fail "`type` should be set, in #{gitlab.html_link(path)}! #{SEE_DOC}" if yaml["type"].nil? if yaml["merge_request"].nil? && !helper.security_mr? - message "Consider setting `merge_request` to #{gitlab.mr_json["iid"]} in #{gitlab.html_link(path)}. #{SEE_DOC}" + mr_line = raw_file.lines.find_index("merge_request:\n") + + if mr_line + markdown(format(SUGGEST_MR_COMMENT, mr_iid: gitlab.mr_json["iid"]), file: path, line: mr_line.succ) + else + message "Consider setting `merge_request` to #{gitlab.mr_json["iid"]} in #{gitlab.html_link(path)}. #{SEE_DOC}" + end elsif yaml["merge_request"] != gitlab.mr_json["iid"] && !helper.security_mr? fail "Merge request ID was not set to #{gitlab.mr_json["iid"]}! #{SEE_DOC}" end diff --git a/db/migrate/20200421054930_remove_index_on_pipeline_id_from_ci_pipeline_variables.rb b/db/migrate/20200421054930_remove_index_on_pipeline_id_from_ci_pipeline_variables.rb new file mode 100644 index 00000000000..1a904188fc6 --- /dev/null +++ b/db/migrate/20200421054930_remove_index_on_pipeline_id_from_ci_pipeline_variables.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class RemoveIndexOnPipelineIdFromCiPipelineVariables < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + INDEX_NAME = 'index_ci_pipeline_variables_on_pipeline_id' + + disable_ddl_transaction! + + def up + remove_concurrent_index_by_name :ci_pipeline_variables, INDEX_NAME + end + + def down + add_concurrent_index :ci_pipeline_variables, :pipeline_id, name: INDEX_NAME, where: "key = 'AUTO_DEVOPS_MODSECURITY_SEC_RULE_ENGINE'" + end +end diff --git a/db/migrate/20200421054948_remove_index_on_pipeline_id_from_ci_variables.rb b/db/migrate/20200421054948_remove_index_on_pipeline_id_from_ci_variables.rb new file mode 100644 index 00000000000..f7e6d10e8a3 --- /dev/null +++ b/db/migrate/20200421054948_remove_index_on_pipeline_id_from_ci_variables.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class RemoveIndexOnPipelineIdFromCiVariables < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + INDEX_NAME = 'index_ci_variables_on_project_id' + + disable_ddl_transaction! + + def up + remove_concurrent_index_by_name :ci_variables, INDEX_NAME + end + + def down + add_concurrent_index :ci_variables, :project_id, name: INDEX_NAME, where: "key = 'AUTO_DEVOPS_MODSECURITY_SEC_RULE_ENGINE'" + end +end diff --git a/db/migrate/20200515152649_enable_btree_gist_extension.rb b/db/migrate/20200515152649_enable_btree_gist_extension.rb deleted file mode 100644 index 686b685fb5d..00000000000 --- a/db/migrate/20200515152649_enable_btree_gist_extension.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -class EnableBtreeGistExtension < ActiveRecord::Migration[6.0] - DOWNTIME = false - - def up - execute 'CREATE EXTENSION IF NOT EXISTS btree_gist' - end - - def down - execute 'DROP EXTENSION IF EXISTS btree_gist' - end -end diff --git a/db/migrate/20200515153633_iteration_date_range_constraint.rb b/db/migrate/20200515153633_iteration_date_range_constraint.rb deleted file mode 100644 index ab197ff8ae7..00000000000 --- a/db/migrate/20200515153633_iteration_date_range_constraint.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -class IterationDateRangeConstraint < ActiveRecord::Migration[6.0] - DOWNTIME = false - - def up - execute <<~SQL - ALTER TABLE sprints - ADD CONSTRAINT iteration_start_and_due_daterange_project_id_constraint - EXCLUDE USING gist - ( project_id WITH =, - daterange(start_date, due_date, '[]') WITH && - ) - WHERE (project_id IS NOT NULL) - SQL - - execute <<~SQL - ALTER TABLE sprints - ADD CONSTRAINT iteration_start_and_due_daterange_group_id_constraint - EXCLUDE USING gist - ( group_id WITH =, - daterange(start_date, due_date, '[]') WITH && - ) - WHERE (group_id IS NOT NULL) - SQL - end - - def down - execute <<~SQL - ALTER TABLE sprints - DROP CONSTRAINT IF EXISTS iteration_start_and_due_daterange_project_id_constraint - SQL - - execute <<~SQL - ALTER TABLE sprints - DROP CONSTRAINT IF EXISTS iteration_start_and_due_daterange_group_id_constraint - SQL - end -end diff --git a/db/post_migrate/20200421195234_backfill_status_page_published_incidents.rb b/db/post_migrate/20200421195234_backfill_status_page_published_incidents.rb new file mode 100644 index 00000000000..fa7a5a9d924 --- /dev/null +++ b/db/post_migrate/20200421195234_backfill_status_page_published_incidents.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class BackfillStatusPagePublishedIncidents < ActiveRecord::Migration[6.0] + DOWNTIME = false + + disable_ddl_transaction! + + class Incident < ActiveRecord::Base + self.table_name = 'status_page_published_incidents' + end + + class StatusPageIssue < ActiveRecord::Base + include ::EachBatch + + self.table_name = 'issues' + + scope :published_only, -> do + joins('INNER JOIN status_page_settings ON status_page_settings.project_id = issues.project_id') + .where('status_page_settings.enabled = true') + .where(confidential: false) + end + end + + def up + current_time = Time.current + + StatusPageIssue.published_only.each_batch do |batch| + incidents = batch.map do |status_page_issue| + { + issue_id: status_page_issue.id, + created_at: current_time, + updated_at: current_time + } + end + + Incident.insert_all(incidents, unique_by: :issue_id) + end + end + + def down + # no op + + # While we expect this table to be empty at the point of + # the up migration, there is no reliable way to determine + # whether records were added as a part of the migration + # or after it has run. + end +end diff --git a/db/structure.sql b/db/structure.sql index b382114514a..590760fc707 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -2,8 +2,6 @@ SET search_path=public; CREATE EXTENSION IF NOT EXISTS plpgsql WITH SCHEMA pg_catalog; -CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA public; - CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public; CREATE TABLE public.abuse_reports ( @@ -8427,12 +8425,6 @@ ALTER TABLE ONLY public.issue_user_mentions ALTER TABLE ONLY public.issues ADD CONSTRAINT issues_pkey PRIMARY KEY (id); -ALTER TABLE ONLY public.sprints - ADD CONSTRAINT iteration_start_and_due_daterange_group_id_constraint EXCLUDE USING gist (group_id WITH =, daterange(start_date, due_date, '[]'::text) WITH &&) WHERE ((group_id IS NOT NULL)); - -ALTER TABLE ONLY public.sprints - ADD CONSTRAINT iteration_start_and_due_daterange_project_id_constraint EXCLUDE USING gist (project_id WITH =, daterange(start_date, due_date, '[]'::text) WITH &&) WHERE ((project_id IS NOT NULL)); - ALTER TABLE ONLY public.jira_connect_installations ADD CONSTRAINT jira_connect_installations_pkey PRIMARY KEY (id); @@ -9338,8 +9330,6 @@ CREATE INDEX index_ci_pipeline_schedules_on_owner_id ON public.ci_pipeline_sched CREATE INDEX index_ci_pipeline_schedules_on_project_id ON public.ci_pipeline_schedules USING btree (project_id); -CREATE INDEX index_ci_pipeline_variables_on_pipeline_id ON public.ci_pipeline_variables USING btree (pipeline_id) WHERE ((key)::text = 'AUTO_DEVOPS_MODSECURITY_SEC_RULE_ENGINE'::text); - CREATE UNIQUE INDEX index_ci_pipeline_variables_on_pipeline_id_and_key ON public.ci_pipeline_variables USING btree (pipeline_id, key); CREATE INDEX index_ci_pipelines_config_on_pipeline_id ON public.ci_pipelines_config USING btree (pipeline_id); @@ -9436,8 +9426,6 @@ CREATE INDEX index_ci_triggers_on_owner_id ON public.ci_triggers USING btree (ow CREATE INDEX index_ci_triggers_on_project_id ON public.ci_triggers USING btree (project_id); -CREATE INDEX index_ci_variables_on_project_id ON public.ci_variables USING btree (project_id) WHERE ((key)::text = 'AUTO_DEVOPS_MODSECURITY_SEC_RULE_ENGINE'::text); - CREATE UNIQUE INDEX index_ci_variables_on_project_id_and_key_and_environment_scope ON public.ci_variables USING btree (project_id, key, environment_scope); CREATE UNIQUE INDEX index_cluster_groups_on_cluster_id_and_group_id ON public.cluster_groups USING btree (cluster_id, group_id); @@ -13885,8 +13873,11 @@ COPY "schema_migrations" (version) FROM STDIN; 20200420172752 20200420172927 20200420201933 +20200421054930 +20200421054948 20200421092907 20200421111005 +20200421195234 20200421233150 20200422091541 20200422213749 @@ -13955,8 +13946,6 @@ COPY "schema_migrations" (version) FROM STDIN; 20200514000009 20200514000132 20200514000340 -20200515152649 -20200515153633 20200515155620 20200519115908 20200519171058 diff --git a/doc/administration/geo/replication/docker_registry.md b/doc/administration/geo/replication/docker_registry.md index e9c69dd32e4..3a1de67e88a 100644 --- a/doc/administration/geo/replication/docker_registry.md +++ b/doc/administration/geo/replication/docker_registry.md @@ -104,7 +104,7 @@ generate a short-lived JWT that is pull-only-capable to access the ```ruby gitlab_rails['geo_registry_replication_enabled'] = true - gitlab_rails['geo_registry_replication_primary_api_url'] = 'http://primary.example.com:4567/' # Primary registry address, it will be used by the secondary node to directly communicate to primary registry + gitlab_rails['geo_registry_replication_primary_api_url'] = 'https://primary.example.com:5050/' # Primary registry address, it will be used by the secondary node to directly communicate to primary registry ``` 1. Reconfigure the **secondary** node for the change to take effect: diff --git a/doc/administration/instance_limits.md b/doc/administration/instance_limits.md index ab67724bda8..a73f40e558e 100644 --- a/doc/administration/instance_limits.md +++ b/doc/administration/instance_limits.md @@ -235,7 +235,7 @@ Kubernetes won't be shown. Reports that go over the 20 MB limit won't be loaded. Affected reports: -- [Merge Request security reports](../user/project/merge_requests/index.md#security-reports-ultimate) +- [Merge Request security reports](../user/project/merge_requests/testing_and_reports_in_merge_requests.md#security-reports-ultimate) - [CI/CD parameter `artifacts:expose_as`](../ci/yaml/README.md#artifactsexpose_as) - [JUnit test reports](../ci/junit_test_reports.md) diff --git a/doc/development/application_limits.md b/doc/development/application_limits.md index b13e2994c52..60138f23cc2 100644 --- a/doc/development/application_limits.md +++ b/doc/development/application_limits.md @@ -115,6 +115,20 @@ it_behaves_like 'includes Limitable concern' do end ``` +### Testing instance-wide limits + +Instance-wide features always use `default` Plan, as instance-wide features +do not have license assigned. + +```ruby +class InstanceVariable + include Limitable + + self.limit_name = 'instance_variables' # Optional as InstanceVariable corresponds with instance_variables + self.limit_scope = Limitable::GLOBAL_SCOPE +end +``` + ### Subscription Plans Self-managed: @@ -123,9 +137,10 @@ Self-managed: GitLab.com: -- `free` - Everyone -- `bronze`- Namespaces with a Bronze subscription -- `silver` - Namespaces with a Silver subscription -- `gold` - Namespaces with a Gold subscription +- `default` - Any system-wide feature +- `free` - Namespaces and projects with a Free subscription +- `bronze`- Namespaces and projects with a Bronze subscription +- `silver` - Namespaces and projects with a Silver subscription +- `gold` - Namespaces and projects with a Gold subscription NOTE: **Note:** The test environment doesn't have any plans. diff --git a/doc/development/documentation/index.md b/doc/development/documentation/index.md index 36ca6079485..95f47a809e8 100644 --- a/doc/development/documentation/index.md +++ b/doc/development/documentation/index.md @@ -67,6 +67,41 @@ Adhere to the [Documentation Style Guide](styleguide.md). If a style standard is See the [Structure](styleguide.md#structure) section of the [Documentation Style Guide](styleguide.md). +## Metadata + +To provide additional directives or useful information, we add metadata in YAML +format to the beginning of each product documentation page. + +For example, the following metadata would be at the beginning of a product +documentation page whose content is primarily associated with the Audit Events +feature: + +```yaml +--- +stage: Monitor +group: APM +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 +--- +``` + +The following list describes the YAML parameters in use: + +- `redirect_to`: The relative path and filename (with an `.md` extension) of the + location to which visitors should be redirected for a moved page. + [Learn more](#changing-document-location). +- `stage`: The [Stage](https://about.gitlab.com/handbook/product/categories/#devops-stages) + to which the majority of the page's content belongs. +- `group`: The [Group](https://about.gitlab.com/company/team/structure/#product-groups) + to which the majority of the page's content belongs. +- `info`: The following line, which provides direction to contributors regarding + how to contact the Technical Writer associated with the page's Stage and + Group: `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` +- `disqus_identifier`: Identifier for Disqus commenting system. Used to keep + comments with a page that's been moved to a new URL. + [Learn more](#redirections-for-pages-with-disqus-comments). + ## Changing document location Changing a document's location requires specific steps to ensure that diff --git a/doc/development/integrations/secure_partner_integration.md b/doc/development/integrations/secure_partner_integration.md index 4520dc2f6a3..460c87b6324 100644 --- a/doc/development/integrations/secure_partner_integration.md +++ b/doc/development/integrations/secure_partner_integration.md @@ -28,7 +28,7 @@ best place to integrate your own product and its results into GitLab. implications for app security, corporate policy, or compliance. When complete, the job reports back on its status and creates a [job artifact](../../user/project/pipelines/job_artifacts.md) as a result. -- The [Merge Request Security Widget](../../user/project/merge_requests/index.md#security-reports-ultimate) +- The [Merge Request Security Widget](../../user/project/merge_requests/testing_and_reports_in_merge_requests.md#security-reports-ultimate) displays the results of the pipeline's security checks and the developer can review them. The developer can review both a summary and a detailed version of the results. @@ -79,7 +79,7 @@ and complete an intgration with the Secure stage. - If you need a new kind of scan or report, [create an issue](https://gitlab.com/gitlab-org/gitlab/-/issues/new#) and add the label `devops::secure`. - Once the job is completed, the data can be seen: - - In the [Merge Request Security Report](../../user/project/merge_requests/index.md#security-reports-ultimate) ([MR Security Report data flow](https://gitlab.com/snippets/1910005#merge-request-view)). + - In the [Merge Request Security Report](../../user/project/merge_requests/testing_and_reports_in_merge_requests.md#security-reports-ultimate) ([MR Security Report data flow](https://gitlab.com/snippets/1910005#merge-request-view)). - While [browsing a Job Artifact](../../user/project/pipelines/job_artifacts.md). - In the [Security Dashboard](../../user/application_security/security_dashboard/index.md) ([Dashboard data flow](https://gitlab.com/snippets/1910005#project-and-group-dashboards)). 1. Optional: Provide a way to interact with results as Vulnerabilities: diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md index 32ea461f4e9..4e10f1657b7 100644 --- a/doc/development/migration_style_guide.md +++ b/doc/development/migration_style_guide.md @@ -367,6 +367,8 @@ migration involves one of the high-traffic tables: - `users` - `projects` - `namespaces` +- `issues` +- `merge_requests` - `ci_pipelines` - `ci_builds` - `notes` diff --git a/doc/install/aws/index.md b/doc/install/aws/index.md index e7fbe392726..18e1d34f9ca 100644 --- a/doc/install/aws/index.md +++ b/doc/install/aws/index.md @@ -59,8 +59,6 @@ Here's a list of the AWS services we will use, with links to pricing information Redis configuration. See the [Amazon ElastiCache pricing](https://aws.amazon.com/elasticache/pricing/). -NOTE: **Note:** Please note that while we will be using EBS for storage, we do not recommend using EFS as it may negatively impact GitLab's performance. You can review the [relevant documentation](../../administration/high_availability/nfs.md#avoid-using-awss-elastic-file-system-efs) for more details. - ## Create an IAM EC2 instance role and profile As we'll be using [Amazon S3 object storage](#amazon-s3-object-storage), our EC2 instances need to have read, write, and list permissions for our S3 buckets. To avoid embedding AWS keys in our GitLab config, we'll make use of an [IAM Role](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles.html) to allow our GitLab instance with this access. We'll need to create an IAM policy to attach to our IAM role: @@ -563,7 +561,7 @@ Let's create an EC2 instance where we'll install Gitaly: 1. Click **Review and launch** followed by **Launch** if you're happy with your settings. 1. Finally, acknowledge that you have access to the selected private key file or create a new one. Click **Launch Instances**. - > **Optional:** Instead of storing configuration _and_ repository data on the root volume, you can also choose to add an additional EBS volume for repository storage. Follow the same guidance as above. See the [Amazon EBS pricing](https://aws.amazon.com/ebs/pricing/). +NOTE: **Optional:** Instead of storing configuration _and_ repository data on the root volume, you can also choose to add an additional EBS volume for repository storage. Follow the same guidance as above. See the [Amazon EBS pricing](https://aws.amazon.com/ebs/pricing/). We do not recommend using EFS as it may negatively impact GitLab’s performance. You can review the [relevant documentation](../../administration/high_availability/nfs.md#avoid-using-awss-elastic-file-system-efs) for more details. Now that we have our EC2 instance ready, follow the [documentation to install GitLab and set up Gitaly on its own server](../../administration/gitaly/index.md#running-gitaly-on-its-own-server). Perform the client setup steps from that document on the [GitLab instance we created](#install-gitlab) above. diff --git a/doc/install/installation.md b/doc/install/installation.md index 741c248129d..3c095004d9c 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -142,7 +142,7 @@ Starting with GitLab 12.0, Git is required to be compiled with `libpcre2`. Find out if that's the case: ```shell -ldd $(which git) | grep pcre2 +ldd $(command -v git) | grep pcre2 ``` The output should contain `libpcre2-8.so.0`. diff --git a/doc/policy/maintenance.md b/doc/policy/maintenance.md index cf2a7a375cc..bec4493a39f 100644 --- a/doc/policy/maintenance.md +++ b/doc/policy/maintenance.md @@ -171,11 +171,10 @@ Please see the table below for some examples: | Latest stable version | Your version | Recommended upgrade path | Note | | --------------------- | ------------ | ------------------------ | ---- | -| 9.4.5 | 8.13.4 | `8.13.4` -> `8.17.7` -> `9.4.5` | `8.17.7` is the last version in version `8` | -| 10.1.4 | 8.13.4 | `8.13.4 -> 8.17.7 -> 9.5.10 -> 10.1.4` | `8.17.7` is the last version in version `8`, `9.5.10` is the last version in version `9` | | 11.3.4 | 8.13.4 | `8.13.4` -> `8.17.7` -> `9.5.10` -> `10.8.7` -> `11.3.4` | `8.17.7` is the last version in version `8`, `9.5.10` is the last version in version `9`, `10.8.7` is the last version in version `10` | | 12.5.10 | 11.3.4 | `11.3.4` -> `11.11.8` -> `12.0.12` -> `12.5.10` | `11.11.8` is the last version in version `11`. `12.0.x` [is a required step](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23211#note_272842444). | | 12.8.5 | 9.2.6 | `9.2.6` -> `9.5.10` -> `10.8.7` -> `11.11.8` -> `12.0.12` -> `12.8.5` | Four intermediate versions are required: the final 9.5, 10.8, 11.11 releases, plus 12.0. | +| 13.2.0 | 11.5.0 | `11.5.0` -> `11.11.8` -> `12.0.12` -> `12.10.6` -> `13.0.0` -> `13.2.0` | Five intermediate versions are required: the final 11.11, 12.0, 12.10 releases, plus 13.0. | NOTE: **Note:** Instructions for installing a specific version of GitLab or downloading the package locally for installation can be found at [GitLab Repositories](https://packages.gitlab.com/gitlab). diff --git a/doc/topics/autodevops/img/guide_pipeline_stages_v12_3.png b/doc/topics/autodevops/img/guide_pipeline_stages_v12_3.png Binary files differdeleted file mode 100644 index b9bab112a9f..00000000000 --- a/doc/topics/autodevops/img/guide_pipeline_stages_v12_3.png +++ /dev/null diff --git a/doc/topics/autodevops/img/guide_pipeline_stages_v13_0.png b/doc/topics/autodevops/img/guide_pipeline_stages_v13_0.png Binary files differnew file mode 100644 index 00000000000..fb102879556 --- /dev/null +++ b/doc/topics/autodevops/img/guide_pipeline_stages_v13_0.png diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md index 1e2264b4e48..36bbb3f77b5 100644 --- a/doc/topics/autodevops/index.md +++ b/doc/topics/autodevops/index.md @@ -393,9 +393,6 @@ To add a different cluster for each environment: 1. Navigate to your project's **{cloud-gear}** **Operations > Kubernetes**. 1. Create the Kubernetes clusters with their respective environment scope, as described from the table above. - - ![Auto DevOps multiple clusters](img/autodevops_multiple_clusters.png) - 1. After creating the clusters, navigate to each cluster and install Helm Tiller and Ingress. Wait for the Ingress IP address to be assigned. 1. Make sure you've [configured your DNS](#auto-devops-base-domain) with the @@ -408,35 +405,6 @@ and verifying your application is deployed as a Review App in the Kubernetes cluster with the `review/*` environment scope. Similarly, you can check the other environments. -## Currently supported languages - -Note that not all buildpacks support Auto Test yet, as it's a relatively new -enhancement. All of Heroku's -[officially supported languages](https://devcenter.heroku.com/articles/heroku-ci#supported-languages) -support Auto Test. The languages supported by Heroku's Herokuish buildpacks all -support Auto Test, but notably the multi-buildpack does not. - -As of GitLab 10.0, the supported buildpacks are: - -```plaintext -- heroku-buildpack-multi v1.0.0 -- heroku-buildpack-ruby v168 -- heroku-buildpack-nodejs v99 -- heroku-buildpack-clojure v77 -- heroku-buildpack-python v99 -- heroku-buildpack-java v53 -- heroku-buildpack-gradle v23 -- heroku-buildpack-scala v78 -- heroku-buildpack-play v26 -- heroku-buildpack-php v122 -- heroku-buildpack-go v72 -- heroku-buildpack-erlang fa17af9 -- buildpack-nginx v8 -``` - -If your application needs a buildpack that is not in the above list, you -might want to use a [custom buildpack](customize.md#custom-buildpacks). - ## Limitations The following restrictions apply. diff --git a/doc/topics/autodevops/quick_start_guide.md b/doc/topics/autodevops/quick_start_guide.md index 859219689f9..2662ba49e87 100644 --- a/doc/topics/autodevops/quick_start_guide.md +++ b/doc/topics/autodevops/quick_start_guide.md @@ -152,8 +152,6 @@ these steps to enable Auto DevOps if it's disabled: After you save your changes, GitLab creates a new pipeline. To view it, go to **{rocket}** **CI/CD > Pipelines**. -![First pipeline](img/guide_first_pipeline_v12_3.png) - In the next section, we explain what each job does in the pipeline. ## Deploy the application @@ -167,7 +165,7 @@ without refreshing the page to **{status_success}** (for success) or The jobs are separated into stages: -![Pipeline stages](img/guide_pipeline_stages_v12_3.png) +![Pipeline stages](img/guide_pipeline_stages_v13_0.png) - **Build** - The application builds a Docker image and uploads it to your project's [Container Registry](../../user/packages/container_registry/index.md) ([Auto Build](stages.md#auto-build)). @@ -182,8 +180,8 @@ The jobs are separated into stages: - The `dependency_scanning` job checks if the application has any dependencies susceptible to vulnerabilities and is allowed to fail ([Auto Dependency Scanning](stages.md#auto-dependency-scanning-ultimate)) **(ULTIMATE)** - - The `sast` job runs static analysis on the current code to check for potential - security issues and is allowed to fail ([Auto SAST](stages.md#auto-sast-ultimate)) **(ULTIMATE)** + - Jobs suffixed with `-sast` run static analysis on the current code to check for potential + security issues, and are allowed to fail ([Auto SAST](stages.md#auto-sast-ultimate)) **(ULTIMATE)** - The `license_management` job searches the application's dependencies to determine each of their licenses and is allowed to fail ([Auto License Compliance](stages.md#auto-license-compliance-ultimate)) **(ULTIMATE)** @@ -191,12 +189,17 @@ The jobs are separated into stages: NOTE: **Note:** All jobs except `test` are allowed to fail in the test stage. +- **Review** - Pipelines on `master` include this stage with a `dast_environment_deploy` job. + To learn more, see [Dynamic Application Security Testing (DAST)](../../user/application_security/dast/index.md). + - **Production** - After the tests and checks finish, the application deploys in Kubernetes ([Auto Deploy](stages.md#auto-deploy)). - **Performance** - Performance tests are run on the deployed application ([Auto Browser Performance Testing](stages.md#auto-browser-performance-testing-premium)). **(PREMIUM)** +- **Cleanup** - Pipelines on `master` include this stage with a `stop_dast_environment` job. + After running a pipeline, you should view your deployed website and learn how to monitor it. diff --git a/doc/topics/autodevops/stages.md b/doc/topics/autodevops/stages.md index 9017d0c6404..91b7323e3e6 100644 --- a/doc/topics/autodevops/stages.md +++ b/doc/topics/autodevops/stages.md @@ -84,11 +84,39 @@ Auto Test runs the appropriate tests for your application using your project to detect the language and framework. Several languages and frameworks are detected automatically, but if your language is not detected, you may be able to create a [custom buildpack](customize.md#custom-buildpacks). -Check the [currently supported languages](index.md#currently-supported-languages). +Check the [currently supported languages](#currently-supported-languages). Auto Test uses tests you already have in your application. If there are no tests, it's up to you to add them. +### Currently supported languages + +Note that not all buildpacks support Auto Test yet, as it's a relatively new +enhancement. All of Heroku's +[officially supported languages](https://devcenter.heroku.com/articles/heroku-ci#supported-languages) +support Auto Test. The languages supported by Heroku's Herokuish buildpacks all +support Auto Test, but notably the multi-buildpack does not. + +The supported buildpacks are: + +```plaintext +- heroku-buildpack-multi +- heroku-buildpack-ruby +- heroku-buildpack-nodejs +- heroku-buildpack-clojure +- heroku-buildpack-python +- heroku-buildpack-java +- heroku-buildpack-gradle +- heroku-buildpack-scala +- heroku-buildpack-play +- heroku-buildpack-php +- heroku-buildpack-go +- buildpack-nginx +``` + +If your application needs a buildpack that is not in the above list, you +might want to use a [custom buildpack](customize.md#custom-buildpacks). + ## Auto Code Quality **(STARTER)** Auto Code Quality uses the diff --git a/doc/topics/web_application_firewall/quick_start_guide.md b/doc/topics/web_application_firewall/quick_start_guide.md index d55ab03a3f2..79435d6a11d 100644 --- a/doc/topics/web_application_firewall/quick_start_guide.md +++ b/doc/topics/web_application_firewall/quick_start_guide.md @@ -150,7 +150,7 @@ By now you should see the pipeline running, but what is it running exactly? To navigate inside the pipeline, click its status badge (its status should be "Running"). The pipeline is split into a few stages, each running a couple of jobs. -![Pipeline stages](../autodevops/img/guide_pipeline_stages_v12_3.png) +![Pipeline stages](../autodevops/img/guide_pipeline_stages_v13_0.png) In the **build** stage, the application is built into a Docker image and then uploaded to your project's [Container Registry](../../user/packages/container_registry/index.md) ([Auto Build](../autodevops/stages.md#auto-build)). diff --git a/doc/user/gitlab_com/index.md b/doc/user/gitlab_com/index.md index 42c4671a47c..9bacfcafbc6 100644 --- a/doc/user/gitlab_com/index.md +++ b/doc/user/gitlab_com/index.md @@ -563,6 +563,10 @@ If more than the maximum number of allowed connections occur concurrently, they dropped and users get [an `ssh_exchange_identification` error](../../topics/git/troubleshooting_git.md#ssh_exchange_identification-error). +### Import/export + +To help avoid abuse, project and group imports, exports, and export downloads are rate limited. See [Project import/export rate limits](../../user/project/settings/import_export.md#rate-limits) and [Group import/export rate limits](../../user/group/settings/import_export.md#rate-limits) for details. + ## GitLab.com Logging We use [Fluentd](https://gitlab.com/gitlab-com/runbooks/tree/master/logging/doc#fluentd) to parse our logs. Fluentd sends our logs to diff --git a/doc/user/group/epics/manage_epics.md b/doc/user/group/epics/manage_epics.md index 7da57f56101..87f20c77443 100644 --- a/doc/user/group/epics/manage_epics.md +++ b/doc/user/group/epics/manage_epics.md @@ -33,7 +33,8 @@ You will be taken to the new epic where can edit the following details: An epic's page contains the following tabs: -- **Epics and Issues**: epics and issues added to this epic. Child epics, and their issues, are shown in a tree view. +- **Epics and Issues**: epics and issues added to this epic. Child epics, and their issues, are + shown in a tree view. - Click the <kbd>></kbd> beside a parent epic to reveal the child epics and issues. - Hover over the total counts to see a breakdown of open and closed items. - **Roadmap**: a roadmap view of child epics which have start and due dates. @@ -137,8 +138,8 @@ confidential** checkbox. ### Enable Confidential Epics **(PREMIUM ONLY)** -The Confidential Epics feature is under development and not ready for production use. It's deployed behind a -feature flag that is **disabled by default**. +The Confidential Epics feature is under development and not ready for production use. +It's deployed behind a feature flag that is **disabled by default**. [GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md) can enable it for your instance. @@ -208,7 +209,7 @@ To remove an issue from an epic: > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/9367) in GitLab 12.5. New issues are added to the top of their list in the **Epics and Issues** tab. -You can reorder the list of issues. Issues and child epics cannot be intermingled. +You can reorder the list of issues. To reorder issues assigned to an epic: @@ -225,7 +226,7 @@ To reorder child epics assigned to an epic: > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/33039) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 13.0. New issues are added to the top of their list in the **Epics and Issues** -tab. You can move issues from one epic to another. Issues and child epics cannot be intermingled. +tab. You can move issues from one epic to another. To move an issue to another epic: @@ -235,7 +236,7 @@ To move an issue to another epic: ### Promote an issue to an epic > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/3777) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 11.6. -> - In [GitLab 12.8](https://gitlab.com/gitlab-org/gitlab/-/issues/37081), it was moved to the Premium tier. +> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/37081) to [GitLab Premium](https://about.gitlab.com/pricing/) in 12.8. If you have [permissions](../../permissions.md) to close an issue and create an epic in the parent group, you can promote an issue to an epic with the `/promote` @@ -266,7 +267,8 @@ To add a child epic to an epic: 1. Click **Add an epic**. 1. Identify the epic to be added, using either of the following methods: - Paste the link of the epic. - - Search for the desired issue by entering part of the epic's title, then selecting the desired match (introduced in [GitLab 12.5](https://gitlab.com/gitlab-org/gitlab/-/issues/9126)). + - Search for the desired issue by entering part of the epic's title, then selecting the desired + match (introduced in [GitLab 12.5](https://gitlab.com/gitlab-org/gitlab/-/issues/9126)). If there are multiple epics to be added, press <kbd>Spacebar</kbd> and repeat this step. 1. Click **Add**. @@ -290,7 +292,7 @@ To move child epics to another epic: > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/9367) in GitLab 12.5. New child epics are added to the top of their list in the **Epics and Issues** tab. -You can reorder the list of child epics. Issues and child epics cannot be intermingled. +You can reorder the list of child epics. To reorder child epics assigned to an epic: diff --git a/doc/user/packages/container_registry/img/container_registry_group_repositories_v13_0.png b/doc/user/packages/container_registry/img/container_registry_group_repositories_v13_0.png Binary files differdeleted file mode 100644 index 14119abd56a..00000000000 --- a/doc/user/packages/container_registry/img/container_registry_group_repositories_v13_0.png +++ /dev/null diff --git a/doc/user/packages/container_registry/img/container_registry_group_repositories_v13_1.png b/doc/user/packages/container_registry/img/container_registry_group_repositories_v13_1.png Binary files differnew file mode 100644 index 00000000000..3ac0407cb2e --- /dev/null +++ b/doc/user/packages/container_registry/img/container_registry_group_repositories_v13_1.png diff --git a/doc/user/packages/container_registry/img/container_registry_repositories_v13_0.png b/doc/user/packages/container_registry/img/container_registry_repositories_v13_0.png Binary files differdeleted file mode 100644 index 7286007b953..00000000000 --- a/doc/user/packages/container_registry/img/container_registry_repositories_v13_0.png +++ /dev/null diff --git a/doc/user/packages/container_registry/img/container_registry_repositories_v13_1.png b/doc/user/packages/container_registry/img/container_registry_repositories_v13_1.png Binary files differnew file mode 100644 index 00000000000..2f07db80a2d --- /dev/null +++ b/doc/user/packages/container_registry/img/container_registry_repositories_v13_1.png diff --git a/doc/user/packages/container_registry/img/container_registry_repositories_with_quickstart_v13_0.png b/doc/user/packages/container_registry/img/container_registry_repositories_with_quickstart_v13_0.png Binary files differdeleted file mode 100644 index f7c3aafcc8e..00000000000 --- a/doc/user/packages/container_registry/img/container_registry_repositories_with_quickstart_v13_0.png +++ /dev/null diff --git a/doc/user/packages/container_registry/img/container_registry_repositories_with_quickstart_v13_1.png b/doc/user/packages/container_registry/img/container_registry_repositories_with_quickstart_v13_1.png Binary files differnew file mode 100644 index 00000000000..0fe575e12aa --- /dev/null +++ b/doc/user/packages/container_registry/img/container_registry_repositories_with_quickstart_v13_1.png diff --git a/doc/user/packages/container_registry/index.md b/doc/user/packages/container_registry/index.md index 17d15339494..5e3f0dfe619 100644 --- a/doc/user/packages/container_registry/index.md +++ b/doc/user/packages/container_registry/index.md @@ -26,7 +26,7 @@ have its own space to store its Docker images. You can read more about Docker Registry at <https://docs.docker.com/registry/introduction/>. -![Container Registry repositories](img/container_registry_repositories_v13_0.png) +![Container Registry repositories](img/container_registry_repositories_v13_1.png) ## Enable the Container Registry for your project @@ -62,7 +62,7 @@ for both projects and groups. Navigate to your project's **{package}** **Packages & Registries > Container Registry**. -![Container Registry project repositories](img/container_registry_repositories_with_quickstart_v13_0.png) +![Container Registry project repositories](img/container_registry_repositories_with_quickstart_v13_1.png) This view will: @@ -77,7 +77,7 @@ This view will: Navigate to your groups's **{package}** **Packages & Registries > Container Registry**. -![Container Registry group repositories](img/container_registry_group_repositories_v13_0.png) +![Container Registry group repositories](img/container_registry_group_repositories_v13_1.png) This view will: diff --git a/doc/user/project/merge_requests/index.md b/doc/user/project/merge_requests/index.md index 8cb0f83f0ea..5d2813f5bfc 100644 --- a/doc/user/project/merge_requests/index.md +++ b/doc/user/project/merge_requests/index.md @@ -86,35 +86,7 @@ See the features at your disposal to [review and manage merge requests](reviewin ## Testing and reports in merge requests -GitLab has the ability to test the changes included in a merge request, and can display -or link to useful information directly in the merge request page: - -| Feature | Description | -|--------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [Accessibility Testing](accessibility_testing.md) | Automatically report A11y violations for changed pages in merge requests | -| [Browser Performance Testing](browser_performance_testing.md) **(PREMIUM)** | Quickly determine the performance impact of pending code changes. | -| [Code Quality](code_quality.md) **(STARTER)** | Analyze your source code quality using the [Code Climate](https://codeclimate.com/) analyzer and show the Code Climate report right in the merge request widget area. | -| [Display arbitrary job artifacts](../../../ci/yaml/README.md#artifactsexpose_as) | Configure CI pipelines with the `artifacts:expose_as` parameter to directly link to selected [artifacts](../../../ci/pipelines/job_artifacts.md) in merge requests. | -| [GitLab CI/CD](../../../ci/README.md) | Build, test, and deploy your code in a per-branch basis with built-in CI/CD. | -| [JUnit test reports](../../../ci/junit_test_reports.md) | Configure your CI jobs to use JUnit test reports, and let GitLab display a report on the merge request so that it’s easier and faster to identify the failure without having to check the entire job log. | -| [License Compliance](../../compliance/license_compliance/index.md) **(ULTIMATE)** | Manage the licenses of your dependencies. | -| [Metrics Reports](../../../ci/metrics_reports.md) **(PREMIUM)** | Display the Metrics Report on the merge request so that it's fast and easy to identify changes to important metrics. | -| [Multi-Project pipelines](../../../ci/multi_project_pipelines.md) **(PREMIUM)** | When you set up GitLab CI/CD across multiple projects, you can visualize the entire pipeline, including all cross-project interdependencies. | -| [Pipelines for merge requests](../../../ci/merge_request_pipelines/index.md) | Customize a specific pipeline structure for merge requests in order to speed the cycle up by running only important jobs. | -| [Pipeline Graphs](../../../ci/pipelines/index.md#visualize-pipelines) | View the status of pipelines within the merge request, including the deployment process. | -| [Test Coverage visualization](test_coverage_visualization.md) | See test coverage results for merge requests, within the file diff. | - -### Security Reports **(ULTIMATE)** - -In addition to the reports listed above, GitLab can do many types of [Security reports](../../application_security/index.md), -generated by scanning and reporting any vulnerabilities found in your project: - -| Feature | Description | -|-----------------------------------------------------------------------------------------|------------------------------------------------------------------| -| [Container Scanning](../../application_security/container_scanning/index.md) | Analyze your Docker images for known vulnerabilities. | -| [Dynamic Application Security Testing (DAST)](../../application_security/dast/index.md) | Analyze your running web applications for known vulnerabilities. | -| [Dependency Scanning](../../application_security/dependency_scanning/index.md) | Analyze your dependencies for known vulnerabilities. | -| [Static Application Security Testing (SAST)](../../application_security/sast/index.md) | Analyze your source code for known vulnerabilities. | +Learn about the options for [testing and reports](testing_and_reports_in_merge_requests.md) on the changes in a merge request. ## Authorization for merge requests diff --git a/doc/user/project/merge_requests/testing_and_reports_in_merge_requests.md b/doc/user/project/merge_requests/testing_and_reports_in_merge_requests.md new file mode 100644 index 00000000000..f7614ed7996 --- /dev/null +++ b/doc/user/project/merge_requests/testing_and_reports_in_merge_requests.md @@ -0,0 +1,36 @@ +--- +type: index +description: "Test your code and display reports in merge requests" +--- + +# Tests and reports in merge requests + +GitLab has the ability to test the changes included in a feature branch and display reports +or link to useful information directly from merge requests: + +| Feature | Description | +|--------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [Accessibility Testing](accessibility_testing.md) | Automatically report A11y violations for changed pages in merge requests. | +| [Browser Performance Testing](browser_performance_testing.md) **(PREMIUM)** | Quickly determine the performance impact of pending code changes. | +| [Code Quality](code_quality.md) **(STARTER)** | Analyze your source code quality using the [Code Climate](https://codeclimate.com/) analyzer and show the Code Climate report right in the merge request widget area. | +| [Display arbitrary job artifacts](../../../ci/yaml/README.md#artifactsexpose_as) | Configure CI pipelines with the `artifacts:expose_as` parameter to directly link to selected [artifacts](../../../ci/pipelines/job_artifacts.md) in merge requests. | +| [GitLab CI/CD](../../../ci/README.md) | Build, test, and deploy your code in a per-branch basis with built-in CI/CD. | +| [JUnit test reports](../../../ci/junit_test_reports.md) | Configure your CI jobs to use JUnit test reports, and let GitLab display a report on the merge request so that it’s easier and faster to identify the failure without having to check the entire job log. | +| [License Compliance](../../compliance/license_compliance/index.md) **(ULTIMATE)** | Manage the licenses of your dependencies. | +| [Metrics Reports](../../../ci/metrics_reports.md) **(PREMIUM)** | Display the Metrics Report on the merge request so that it's fast and easy to identify changes to important metrics. | +| [Multi-Project pipelines](../../../ci/multi_project_pipelines.md) **(PREMIUM)** | When you set up GitLab CI/CD across multiple projects, you can visualize the entire pipeline, including all cross-project interdependencies. | +| [Pipelines for merge requests](../../../ci/merge_request_pipelines/index.md) | Customize a specific pipeline structure for merge requests in order to speed the cycle up by running only important jobs. | +| [Pipeline Graphs](../../../ci/pipelines/index.md#visualize-pipelines) | View the status of pipelines within the merge request, including the deployment process. | +| [Test Coverage visualization](test_coverage_visualization.md) | See test coverage results for merge requests, within the file diff. | + +## Security Reports **(ULTIMATE)** + +In addition to the reports listed above, GitLab can do many types of [Security reports](../../application_security/index.md), +generated by scanning and reporting any vulnerabilities found in your project: + +| Feature | Description | +|-----------------------------------------------------------------------------------------|------------------------------------------------------------------| +| [Container Scanning](../../application_security/container_scanning/index.md) | Analyze your Docker images for known vulnerabilities. | +| [Dynamic Application Security Testing (DAST)](../../application_security/dast/index.md) | Analyze your running web applications for known vulnerabilities. | +| [Dependency Scanning](../../application_security/dependency_scanning/index.md) | Analyze your dependencies for known vulnerabilities. | +| [Static Application Security Testing (SAST)](../../application_security/sast/index.md) | Analyze your source code for known vulnerabilities. | diff --git a/doc/user/project/settings/project_access_tokens.md b/doc/user/project/settings/project_access_tokens.md index 303a6f6d3be..460a5b6f88d 100644 --- a/doc/user/project/settings/project_access_tokens.md +++ b/doc/user/project/settings/project_access_tokens.md @@ -1,6 +1,13 @@ -# Project access tokens **(CORE ONLY)** +# Project access tokens (Alpha) **(CORE ONLY)** -> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/2587) in GitLab 13.0. +CAUTION: **Warning:** +This is an [Alpha](https://about.gitlab.com/handbook/product/#alpha) feature, and it is subject to change at any time without +prior notice. + +> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/2587) in GitLab 13.0. +> - It's deployed behind a feature flag, disabled by default. +> - It's disabled on GitLab.com. +> - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-project-access-tokens). Project access tokens are scoped to a project and can be used to authenticate with the [GitLab API](../../../api/README.md#personalproject-access-tokens). @@ -53,3 +60,22 @@ the following table. | `write_registry` | Allows write-access (push) to [container registry](../../packages/container_registry/index.md). | | `read_repository` | Allows read-only access (pull) to the repository. | | `write_repository` | Allows read-write access (pull, push) to the repository. | + +### Enable or disable project access tokens + +Project access tokens is an [Alpha](https://about.gitlab.com/handbook/product/#alpha) feature and is not recommended for production use. +It is deployed behind a feature flag that is **disabled by default**. +[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md) +can enable it for your instance. + +To enable it: + +```ruby +Feature.enable(:resource_access_token) +``` + +To disable it: + +```ruby +Feature.disable(:resource_access_token) +``` diff --git a/lib/api/commit_statuses.rb b/lib/api/commit_statuses.rb index fd09e2d4ee6..b4c5d7869a2 100644 --- a/lib/api/commit_statuses.rb +++ b/lib/api/commit_statuses.rb @@ -106,7 +106,7 @@ module API status.enqueue! when 'running' status.enqueue - Gitlab::OptimisticLocking.retry_lock(status, &:run!) + status.run! when 'success' status.success! when 'failed' diff --git a/lib/api/helpers/runner.rb b/lib/api/helpers/runner.rb index 1f1253c8542..293d7ed9a6a 100644 --- a/lib/api/helpers/runner.rb +++ b/lib/api/helpers/runner.rb @@ -3,6 +3,8 @@ module API module Helpers module Runner + include Gitlab::Utils::StrongMemoize + prepend_if_ee('EE::API::Helpers::Runner') # rubocop: disable Cop/InjectEnterpriseEditionModule JOB_TOKEN_HEADER = 'HTTP_JOB_TOKEN' @@ -16,7 +18,7 @@ module API forbidden! unless current_runner current_runner - .update_cached_info(get_runner_details_from_request) + .heartbeat(get_runner_details_from_request) end def get_runner_details_from_request @@ -31,31 +33,35 @@ module API end def current_runner - @runner ||= ::Ci::Runner.find_by_token(params[:token].to_s) + strong_memoize(:current_runner) do + ::Ci::Runner.find_by_token(params[:token].to_s) + end end - def validate_job!(job) - not_found! unless job + def authenticate_job!(require_running: true) + job = current_job - yield if block_given? + not_found! unless job + forbidden! unless job_token_valid?(job) - project = job.project - forbidden!('Project has been deleted!') if project.nil? || project.pending_delete? + forbidden!('Project has been deleted!') if job.project.nil? || job.project.pending_delete? forbidden!('Job has been erased!') if job.erased? - end - def authenticate_job! - job = current_job + if require_running + job_forbidden!(job, 'Job is not running') unless job.running? + end - validate_job!(job) do - forbidden! unless job_token_valid?(job) + if Gitlab::Ci::Features.job_heartbeats_runner?(job.project) + job.runner&.heartbeat(get_runner_ip) end job end def current_job - @current_job ||= Ci::Build.find_by_id(params[:id]) + strong_memoize(:current_job) do + Ci::Build.find_by_id(params[:id]) + end end def job_token_valid?(job) diff --git a/lib/api/issues.rb b/lib/api/issues.rb index de2d0b01a64..2374ac11f4a 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -5,7 +5,6 @@ module API include PaginationParams helpers Helpers::IssuesHelpers helpers Helpers::RateLimiter - helpers ::Gitlab::IssuableMetadata before { authenticate_non_get! } @@ -108,7 +107,7 @@ module API with: Entities::Issue, with_labels_details: declared_params[:with_labels_details], current_user: current_user, - issuable_metadata: issuable_meta_data(issues, 'Issue', current_user), + issuable_metadata: Gitlab::IssuableMetadata.new(current_user, issues).data, include_subscribed: false } @@ -134,7 +133,7 @@ module API with: Entities::Issue, with_labels_details: declared_params[:with_labels_details], current_user: current_user, - issuable_metadata: issuable_meta_data(issues, 'Issue', current_user), + issuable_metadata: Gitlab::IssuableMetadata.new(current_user, issues).data, include_subscribed: false, group: user_group } @@ -171,7 +170,7 @@ module API with_labels_details: declared_params[:with_labels_details], current_user: current_user, project: user_project, - issuable_metadata: issuable_meta_data(issues, 'Issue', current_user), + issuable_metadata: Gitlab::IssuableMetadata.new(current_user, issues).data, include_subscribed: false } diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index ff4ad85115b..32e4059f054 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -8,7 +8,6 @@ module API before { authenticate_non_get! } - helpers ::Gitlab::IssuableMetadata helpers Helpers::MergeRequestsHelpers # EE::API::MergeRequests would override the following helpers @@ -92,7 +91,7 @@ module API if params[:view] == 'simple' options[:with] = Entities::MergeRequestSimple else - options[:issuable_metadata] = issuable_meta_data(merge_requests, 'MergeRequest', current_user) + options[:issuable_metadata] = Gitlab::IssuableMetadata.new(current_user, merge_requests).data if Feature.enabled?(:mr_list_api_skip_merge_status_recheck, default_enabled: true) options[:skip_merge_status_recheck] = !declared_params[:with_merge_status_recheck] end diff --git a/lib/api/runner.rb b/lib/api/runner.rb index e070e57a376..5f08ebe4a06 100644 --- a/lib/api/runner.rb +++ b/lib/api/runner.rb @@ -154,7 +154,6 @@ module API end put '/:id' do job = authenticate_job! - job_forbidden!(job, 'Job is not running') unless job.running? job.trace.set(params[:trace]) if params[:trace] @@ -182,7 +181,6 @@ module API end patch '/:id/trace' do job = authenticate_job! - job_forbidden!(job, 'Job is not running') unless job.running? error!('400 Missing header Content-Range', 400) unless request.headers.key?('Content-Range') content_range = request.headers['Content-Range'] @@ -229,7 +227,6 @@ module API Gitlab::Workhorse.verify_api_request!(headers) job = authenticate_job! - forbidden!('Job is not running') unless job.running? service = Ci::AuthorizeJobArtifactService.new(job, params, max_size: max_artifacts_size(job)) @@ -265,7 +262,6 @@ module API require_gitlab_workhorse! job = authenticate_job! - forbidden!('Job is not running!') unless job.running? artifacts = params[:file] metadata = params[:metadata] @@ -292,7 +288,7 @@ module API optional :direct_download, default: false, type: Boolean, desc: %q(Perform direct download from remote storage instead of proxying artifacts) end get '/:id/artifacts' do - job = authenticate_job! + job = authenticate_job!(require_running: false) present_carrierwave_file!(job.artifacts_file, supports_direct_download: params[:direct_download]) end diff --git a/lib/api/todos.rb b/lib/api/todos.rb index 02b8bb55274..43ed9c96486 100644 --- a/lib/api/todos.rb +++ b/lib/api/todos.rb @@ -6,8 +6,6 @@ module API before { authenticate! } - helpers ::Gitlab::IssuableMetadata - ISSUABLE_TYPES = { 'merge_requests' => ->(iid) { find_merge_request_with_access(iid) }, 'issues' => ->(iid) { find_project_issue(iid) } @@ -65,7 +63,7 @@ module API next unless collection targets = collection.map(&:target) - options[type] = { issuable_metadata: issuable_meta_data(targets, type, current_user) } + options[type] = { issuable_metadata: Gitlab::IssuableMetadata.new(current_user, targets).data } end end end diff --git a/lib/gitlab/ci/features.rb b/lib/gitlab/ci/features.rb index 48f3d4fdd2f..06db38d1d7b 100644 --- a/lib/gitlab/ci/features.rb +++ b/lib/gitlab/ci/features.rb @@ -13,6 +13,10 @@ module Gitlab def self.ensure_scheduling_type_enabled? ::Feature.enabled?(:ci_ensure_scheduling_type, default_enabled: true) end + + def self.job_heartbeats_runner?(project) + ::Feature.enabled?(:ci_job_heartbeats_runner, project, default_enabled: true) + end end end end diff --git a/lib/gitlab/issuable_metadata.rb b/lib/gitlab/issuable_metadata.rb index 6f760751b0f..e946fc00c4d 100644 --- a/lib/gitlab/issuable_metadata.rb +++ b/lib/gitlab/issuable_metadata.rb @@ -1,8 +1,52 @@ # frozen_string_literal: true module Gitlab - module IssuableMetadata - def issuable_meta_data(issuable_collection, collection_type, user = nil) + class IssuableMetadata + include Gitlab::Utils::StrongMemoize + + # data structure to store issuable meta data like + # upvotes, downvotes, notes and closing merge requests counts for issues and merge requests + # this avoiding n+1 queries when loading issuable collections on frontend + IssuableMeta = Struct.new(:upvotes, :downvotes, :user_notes_count, :mrs_count) do + def merge_requests_count(user = nil) + mrs_count + end + end + + attr_reader :current_user, :issuable_collection + + def initialize(current_user, issuable_collection) + @current_user = current_user + @issuable_collection = issuable_collection + + validate_collection! + end + + def data + return {} if issuable_ids.empty? + + issuable_ids.each_with_object({}) do |id, issuable_meta| + issuable_meta[id] = metadata_for_issuable(id) + end + end + + private + + def metadata_for_issuable(id) + downvotes = group_issuable_votes_count.find { |votes| votes.awardable_id == id && votes.downvote? } + upvotes = group_issuable_votes_count.find { |votes| votes.awardable_id == id && votes.upvote? } + notes = grouped_issuable_notes_count.find { |notes| notes.noteable_id == id } + merge_requests = grouped_issuable_merge_requests_count.find { |mr| mr.first == id } + + IssuableMeta.new( + upvotes.try(:count).to_i, + downvotes.try(:count).to_i, + notes.try(:count).to_i, + merge_requests.try(:last).to_i + ) + end + + def validate_collection! # ActiveRecord uses Object#extend for null relations. if !(issuable_collection.singleton_class < ActiveRecord::NullRelation) && issuable_collection.respond_to?(:limit_value) && @@ -10,36 +54,43 @@ module Gitlab raise 'Collection must have a limit applied for preloading meta-data' end + end - # map has to be used here since using pluck or select will - # throw an error when ordering issuables by priority which inserts - # a new order into the collection. - # We cannot use reorder to not mess up the paginated collection. - issuable_ids = issuable_collection.map(&:id) + def issuable_ids + strong_memoize(:issuable_ids) do + # map has to be used here since using pluck or select will + # throw an error when ordering issuables by priority which inserts + # a new order into the collection. + # We cannot use reorder to not mess up the paginated collection. + issuable_collection.map(&:id) + end + end - return {} if issuable_ids.empty? + def collection_type + # Supports relations or paginated arrays + issuable_collection.try(:model)&.name || + issuable_collection.first&.model_name.to_s + end - issuable_notes_count = ::Note.count_for_collection(issuable_ids, collection_type) - issuable_votes_count = ::AwardEmoji.votes_for_collection(issuable_ids, collection_type) - issuable_merge_requests_count = + def group_issuable_votes_count + strong_memoize(:group_issuable_votes_count) do + AwardEmoji.votes_for_collection(issuable_ids, collection_type) + end + end + + def grouped_issuable_notes_count + strong_memoize(:grouped_issuable_notes_count) do + ::Note.count_for_collection(issuable_ids, collection_type) + end + end + + def grouped_issuable_merge_requests_count + strong_memoize(:grouped_issuable_merge_requests_count) do if collection_type == 'Issue' - ::MergeRequestsClosingIssues.count_for_collection(issuable_ids, user) + ::MergeRequestsClosingIssues.count_for_collection(issuable_ids, current_user) else [] end - - issuable_ids.each_with_object({}) do |id, issuable_meta| - downvotes = issuable_votes_count.find { |votes| votes.awardable_id == id && votes.downvote? } - upvotes = issuable_votes_count.find { |votes| votes.awardable_id == id && votes.upvote? } - notes = issuable_notes_count.find { |notes| notes.noteable_id == id } - merge_requests = issuable_merge_requests_count.find { |mr| mr.first == id } - - issuable_meta[id] = ::Issuable::IssuableMeta.new( - upvotes.try(:count).to_i, - downvotes.try(:count).to_i, - notes.try(:count).to_i, - merge_requests.try(:last).to_i - ) end end end diff --git a/lib/object_storage/direct_upload.rb b/lib/object_storage/direct_upload.rb index fd26663fef0..b3c0e68dbb3 100644 --- a/lib/object_storage/direct_upload.rb +++ b/lib/object_storage/direct_upload.rb @@ -2,7 +2,7 @@ module ObjectStorage # - # The DirectUpload c;ass generates a set of presigned URLs + # The DirectUpload class generates a set of presigned URLs # that can be used to upload data to object storage from untrusted component: Workhorse, Runner? # # For Google it assumes that the platform supports variable Content-Length. diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 3fd94e01730..eb14b11e80a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2231,9 +2231,6 @@ msgstr "" msgid "An error occurred while loading chart data" msgstr "" -msgid "An error occurred while loading clusters" -msgstr "" - msgid "An error occurred while loading commit signatures" msgstr "" @@ -5429,6 +5426,9 @@ msgstr "" msgid "ClusterIntergation|Select service role" msgstr "" +msgid "Clusters|An error occurred while loading clusters" +msgstr "" + msgid "Code" msgstr "" @@ -5839,6 +5839,11 @@ msgstr "" msgid "ContainerRegistry| Please visit the %{linkStart}administration settings%{linkEnd} to enable this feature." msgstr "" +msgid "ContainerRegistry|%{count} Image repository" +msgid_plural "ContainerRegistry|%{count} Image repositories" +msgstr[0] "" +msgstr[1] "" + msgid "ContainerRegistry|%{imageName} tags" msgstr "" @@ -5851,6 +5856,9 @@ msgstr "" msgid "ContainerRegistry|Build an image" msgstr "" +msgid "ContainerRegistry|CLI Commands" +msgstr "" + msgid "ContainerRegistry|Compressed Size" msgstr "" @@ -5875,15 +5883,21 @@ msgstr "" msgid "ContainerRegistry|Docker tag expiration policy is %{toggleStatus}" msgstr "" -msgid "ContainerRegistry|Edit Settings" +msgid "ContainerRegistry|Expiration interval:" msgstr "" -msgid "ContainerRegistry|Expiration interval:" +msgid "ContainerRegistry|Expiration policies help manage the storage space used by the Container Registry, but the expiration policies for this registry are disabled. Contact your administrator to enable. %{docLinkStart}More information%{docLinkEnd}" +msgstr "" + +msgid "ContainerRegistry|Expiration policy is disabled" msgstr "" msgid "ContainerRegistry|Expiration policy successfully saved." msgstr "" +msgid "ContainerRegistry|Expiration policy will run in %{time}" +msgstr "" + msgid "ContainerRegistry|Expiration policy:" msgstr "" @@ -5923,9 +5937,6 @@ msgstr "" msgid "ContainerRegistry|Push an image" msgstr "" -msgid "ContainerRegistry|Quick Start" -msgstr "" - msgid "ContainerRegistry|Regular expressions such as %{codeStart}.*-master%{codeEnd} or %{codeStart}release-.*%{codeEnd} are supported" msgstr "" @@ -5946,9 +5957,6 @@ msgid_plural "ContainerRegistry|Remove tags" msgstr[0] "" msgstr[1] "" -msgid "ContainerRegistry|Retention policy has been Enabled" -msgstr "" - msgid "ContainerRegistry|Something went wrong while fetching the expiration policy." msgstr "" @@ -6000,12 +6008,6 @@ msgstr "" msgid "ContainerRegistry|The last tag related to this image was recently removed. This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process. If you have any questions, contact your administrator." msgstr "" -msgid "ContainerRegistry|The retention and expiration policy for this Container Registry has been enabled and will run in %{days}. For more information visit the %{linkStart}documentation%{linkEnd}" -msgstr "" - -msgid "ContainerRegistry|The retention and expiration policy for this Container Registry has been enabled. For more information visit the %{linkStart}documentation%{linkEnd}" -msgstr "" - msgid "ContainerRegistry|The value of this input should be less than 255 characters" msgstr "" @@ -6027,7 +6029,7 @@ msgstr "" msgid "ContainerRegistry|To widen your search, change or remove the filters above." msgstr "" -msgid "ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}" +msgid "ContainerRegistry|We are having trouble connecting to the Registry, which could be due to an issue with your project name or path. %{docLinkStart}More information%{docLinkEnd}" msgstr "" msgid "ContainerRegistry|With the Container Registry, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}" @@ -6036,7 +6038,7 @@ msgstr "" msgid "ContainerRegistry|With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here. %{docLinkStart}More Information%{docLinkEnd}" msgstr "" -msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}" +msgid "ContainerRegistry|With the GitLab Container Registry, every project can have its own space to store images. %{docLinkStart}More information%{docLinkEnd}" msgstr "" msgid "ContainerRegistry|You are about to remove %{item} tags. Are you sure?" @@ -8993,6 +8995,9 @@ msgstr "" msgid "Expand up" msgstr "" +msgid "Experienced" +msgstr "" + msgid "Expiration" msgstr "" @@ -9239,6 +9244,9 @@ msgstr "" msgid "Failed to protect the environment" msgstr "" +msgid "Failed to publish issue on status page." +msgstr "" + msgid "Failed to remove a Zoom meeting" msgstr "" @@ -12083,6 +12091,9 @@ msgstr "" msgid "Issue or Merge Request ID is required" msgstr "" +msgid "Issue published on status page." +msgstr "" + msgid "Issue template (optional)" msgstr "" @@ -12200,6 +12211,12 @@ msgstr "" msgid "It's you" msgstr "" +msgid "Iteration changed to" +msgstr "" + +msgid "Iteration removed" +msgstr "" + msgid "Iterations" msgstr "" @@ -12212,6 +12229,12 @@ msgstr "" msgid "Iteration|cannot be more than 500 years in the future" msgstr "" +msgid "I’m familiar with the basics of project management and DevOps." +msgstr "" + +msgid "I’m not very familiar with the basics of project management and DevOps." +msgstr "" + msgid "Jaeger URL" msgstr "" @@ -13472,6 +13495,9 @@ msgstr "" msgid "Merge request %{iid} authored by %{authorName}" msgstr "" +msgid "Merge request %{mr_link} was reviewed by %{mr_author}" +msgstr "" + msgid "Merge request approvals" msgstr "" @@ -14795,6 +14821,9 @@ msgstr "" msgid "November" msgstr "" +msgid "Novice" +msgstr "" + msgid "Now you can access the merge request navigation tabs at the top, where they’re easier to find." msgstr "" @@ -15034,7 +15063,7 @@ msgstr "" msgid "Optional" msgstr "" -msgid "Optional parameter \"variables\" must be an array of keys and values. Ex: [key1, value1, key2, value2]" +msgid "Optional parameter \"variables\" must be a Hash. Ex: variables[key1]=value1" msgstr "" msgid "Optionally, you can %{link_to_customize} how FogBugz email addresses and usernames are imported into GitLab." @@ -17548,6 +17577,12 @@ msgstr "" msgid "Public projects Minutes cost factor" msgstr "" +msgid "Publish to status page" +msgstr "" + +msgid "Publishes this issue to the associated status page." +msgstr "" + msgid "Pull" msgstr "" @@ -19866,9 +19901,15 @@ msgstr "" msgid "Show latest version" msgstr "" +msgid "Show me everything" +msgstr "" + msgid "Show me how" msgstr "" +msgid "Show me more advanced stuff" +msgstr "" + msgid "Show only direct members" msgstr "" @@ -22388,6 +22429,9 @@ msgstr "" msgid "This project does not belong to a group and can therefore not make use of group Runners." msgstr "" +msgid "This project does not have %{service_desk_link_start}Service Desk%{service_desk_link_end} enabled, so the user who created the issue will no longer receive email notifications about new activity." +msgstr "" + msgid "This project does not have a wiki homepage yet" msgstr "" @@ -24643,12 +24687,21 @@ msgstr "" msgid "Welcome to the Guided GitLab Tour" msgstr "" +msgid "Welcome to the guided GitLab tour" +msgstr "" + msgid "Welcome to your Issue Board!" msgstr "" msgid "What are you searching for?" msgstr "" +msgid "What describes you best?" +msgstr "" + +msgid "What’s your experience level?" +msgstr "" + msgid "When a deployment job is successful, skip older deployment jobs that are still pending" msgstr "" @@ -26001,10 +26054,10 @@ msgstr "" msgid "exceeds the limit of %{bytes} bytes for directory name \"%{dirname}\"" msgstr "" -msgid "expired on %{milestone_due_date}" +msgid "expired on %{timebox_due_date}" msgstr "" -msgid "expires on %{milestone_due_date}" +msgid "expires on %{timebox_due_date}" msgstr "" msgid "external_url" @@ -26779,10 +26832,10 @@ msgstr "" msgid "started a discussion on %{design_link}" msgstr "" -msgid "started on %{milestone_start_date}" +msgid "started on %{timebox_start_date}" msgstr "" -msgid "starts on %{milestone_start_date}" +msgid "starts on %{timebox_start_date}" msgstr "" msgid "stuck" diff --git a/package.json b/package.json index 771584d5499..6dda80eb8da 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "@babel/preset-env": "^7.8.4", "@gitlab/at.js": "1.5.5", "@gitlab/svgs": "1.130.0", - "@gitlab/ui": "16.0", + "@gitlab/ui": "16.0.0", "@gitlab/visual-review-tools": "1.6.1", "@rails/actioncable": "^6.0.3", "@sentry/browser": "^5.10.2", @@ -89,6 +89,7 @@ module QA autoload :ProjectSnippet, 'qa/resource/project_snippet' autoload :UserGPG, 'qa/resource/user_gpg' autoload :Visibility, 'qa/resource/visibility' + autoload :ProjectSnippet, 'qa/resource/project_snippet' module KubernetesCluster autoload :Base, 'qa/resource/kubernetes_cluster/base' diff --git a/qa/qa/git/repository.rb b/qa/qa/git/repository.rb index bd2fbbd80cb..43608827b2e 100644 --- a/qa/qa/git/repository.rb +++ b/qa/qa/git/repository.rb @@ -111,7 +111,7 @@ module QA end def commit_with_gpg(message) - run(%Q{git config user.signingkey #{@gpg_key_id} && git config gpg.program $(which gpg) && git commit -S -m "#{message}"}).to_s + run(%Q{git config user.signingkey #{@gpg_key_id} && git config gpg.program $(command -v gpg) && git commit -S -m "#{message}"}).to_s end def push_changes(branch = 'master') diff --git a/qa/qa/page/dashboard/snippet/show.rb b/qa/qa/page/dashboard/snippet/show.rb index d43b64cd1d4..52fbf6fba73 100644 --- a/qa/qa/page/dashboard/snippet/show.rb +++ b/qa/qa/page/dashboard/snippet/show.rb @@ -35,6 +35,10 @@ module QA element :file_content end + view 'app/assets/javascripts/blob/components/blob_content.vue' do + element :file_content + end + view 'app/assets/javascripts/snippets/components/snippet_header.vue' do element :snippet_action_button element :delete_snippet_button @@ -57,6 +61,10 @@ module QA has_element? :snippet_description_field, text: snippet_description end + def has_no_snippet_description? + has_no_element?(:snippet_description_field) + end + def has_visibility_type?(visibility_type) within_element(:snippet_box) do has_text?(visibility_type) diff --git a/qa/qa/specs/features/browser_ui/3_create/snippet/create_snippet_spec.rb b/qa/qa/specs/features/browser_ui/3_create/snippet/create_personal_snippet_spec.rb index dfcbf4b44c8..451a7847f8b 100644 --- a/qa/qa/specs/features/browser_ui/3_create/snippet/create_snippet_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/snippet/create_personal_snippet_spec.rb @@ -2,8 +2,8 @@ module QA context 'Create', :smoke do - describe 'Snippet creation' do - it 'User creates a snippet' do + describe 'Personal snippet creation' do + it 'User creates a personal snippet' do Flow::Login.sign_in Page::Main::Menu.perform(&:go_to_snippets) diff --git a/qa/qa/specs/features/browser_ui/3_create/snippet/create_project_snippet_spec.rb b/qa/qa/specs/features/browser_ui/3_create/snippet/create_project_snippet_spec.rb new file mode 100644 index 00000000000..0d7253710b2 --- /dev/null +++ b/qa/qa/specs/features/browser_ui/3_create/snippet/create_project_snippet_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module QA + context 'Create' do # to be converted to a smoke test once proved to be stable + describe 'Project snippet creation' do + it 'User creates a project snippet' do + Flow::Login.sign_in + + Resource::ProjectSnippet.fabricate_via_browser_ui! do |snippet| + snippet.title = 'Project snippet' + snippet.description = ' ' + snippet.visibility = 'Internal' + snippet.file_name = 'markdown_file.md' + snippet.file_content = "### Snippet heading\n\n[Gitlab link](https://gitlab.com/)" + end + + Page::Dashboard::Snippet::Show.perform do |snippet| + expect(snippet).to have_snippet_title('Project snippet') + expect(snippet).to have_no_snippet_description + expect(snippet).to have_visibility_type(/internal/i) + expect(snippet).to have_file_name('markdown_file.md') + expect(snippet).to have_file_content('Snippet heading') + expect(snippet).to have_file_content('Gitlab link') + expect(snippet).not_to have_file_content('###') + expect(snippet).not_to have_file_content('https://gitlab.com/') + end + end + end + end +end diff --git a/spec/controllers/admin/clusters_controller_spec.rb b/spec/controllers/admin/clusters_controller_spec.rb index d4a12e0dc52..fc1328c887a 100644 --- a/spec/controllers/admin/clusters_controller_spec.rb +++ b/spec/controllers/admin/clusters_controller_spec.rb @@ -42,6 +42,13 @@ describe Admin::ClustersController do expect(response).to match_response_schema('cluster_list') end + it 'sets the polling interval header for json requests' do + get_index(format: :json) + + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers['Poll-Interval']).to eq("10000") + end + context 'when page is specified' do let(:last_page) { Clusters::Cluster.instance_type.page.total_pages } let(:total_count) { Clusters::Cluster.instance_type.page.total_count } diff --git a/spec/controllers/groups/clusters_controller_spec.rb b/spec/controllers/groups/clusters_controller_spec.rb index 1f2f6bd811b..57a6db54338 100644 --- a/spec/controllers/groups/clusters_controller_spec.rb +++ b/spec/controllers/groups/clusters_controller_spec.rb @@ -47,6 +47,13 @@ describe Groups::ClustersController do expect(response).to match_response_schema('cluster_list') end + it 'sets the polling interval header for json requests' do + go(format: :json) + + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers['Poll-Interval']).to eq("10000") + end + context 'when page is specified' do let(:last_page) { group.clusters.page.total_pages } let(:total_count) { group.clusters.page.total_count } diff --git a/spec/controllers/projects/clusters_controller_spec.rb b/spec/controllers/projects/clusters_controller_spec.rb index 698a3773d59..262a4956ce5 100644 --- a/spec/controllers/projects/clusters_controller_spec.rb +++ b/spec/controllers/projects/clusters_controller_spec.rb @@ -41,6 +41,13 @@ describe Projects::ClustersController do expect(response).to match_response_schema('cluster_list') end + it 'sets the polling interval header for json requests' do + go(format: :json) + + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers['Poll-Interval']).to eq("10000") + end + context 'when page is specified' do let(:last_page) { project.clusters.page.total_pages } let(:total_count) { project.clusters.page.total_count } diff --git a/spec/controllers/projects/environments/prometheus_api_controller_spec.rb b/spec/controllers/projects/environments/prometheus_api_controller_spec.rb index 64f90e44bb6..fb8da52930c 100644 --- a/spec/controllers/projects/environments/prometheus_api_controller_spec.rb +++ b/spec/controllers/projects/environments/prometheus_api_controller_spec.rb @@ -84,12 +84,12 @@ describe Projects::Environments::PrometheusApiController do before do expected_params[:query] = %{up{pod_name="#{pod_name}"}} - expected_params[:variables] = ['pod_name', pod_name] + expected_params[:variables] = { 'pod_name' => pod_name } end it 'replaces variables with values' do get :proxy, params: environment_params.merge( - query: 'up{pod_name="{{pod_name}}"}', variables: ['pod_name', pod_name] + query: 'up{pod_name="{{pod_name}}"}', variables: { 'pod_name' => pod_name } ) expect(response).to have_gitlab_http_status(:success) diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb index 01a9647a763..3a6ddfb1783 100644 --- a/spec/controllers/registrations_controller_spec.rb +++ b/spec/controllers/registrations_controller_spec.rb @@ -445,4 +445,27 @@ describe RegistrationsController do end end end + + describe '#experience_level' do + subject { get :experience_level } + + let_it_be(:user) { create(:user) } + + let(:part_of_onboarding_issues_experiment) { false } + + before do + stub_experiment_for_user(onboarding_issues: part_of_onboarding_issues_experiment) + sign_in(user) + end + + context 'when not part of the onboarding issues experiment' do + it { is_expected.to have_gitlab_http_status(:not_found) } + end + + context 'when part of the onboarding issues experiment' do + let(:part_of_onboarding_issues_experiment) { true } + + it { is_expected.to render_template(:experience_level) } + end + end end diff --git a/spec/features/projects/container_registry_spec.rb b/spec/features/projects/container_registry_spec.rb index a75da5f1080..19f2bf8107b 100644 --- a/spec/features/projects/container_registry_spec.rb +++ b/spec/features/projects/container_registry_spec.rb @@ -30,10 +30,10 @@ describe 'Container Registry', :js do expect(page).to have_content _('There are no container images stored for this project') end - it 'list page has quickstart' do + it 'list page has cli commands' do visit_container_registry - expect(page).to have_content _('Quick Start') + expect(page).to have_content _('CLI Commands') end end diff --git a/spec/frontend/blob/components/blob_header_filepath_spec.js b/spec/frontend/blob/components/blob_header_filepath_spec.js index 3a53208f357..43057353051 100644 --- a/spec/frontend/blob/components/blob_header_filepath_spec.js +++ b/spec/frontend/blob/components/blob_header_filepath_spec.js @@ -4,9 +4,8 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import { Blob as MockBlob } from './mock_data'; import { numberToHumanSize } from '~/lib/utils/number_utils'; -const mockHumanReadableSize = 'a lot'; jest.mock('~/lib/utils/number_utils', () => ({ - numberToHumanSize: jest.fn(() => mockHumanReadableSize), + numberToHumanSize: jest.fn(() => 'a lot'), })); describe('Blob Header Filepath', () => { @@ -57,7 +56,7 @@ describe('Blob Header Filepath', () => { it('renders filesize in a human-friendly format', () => { createComponent(); expect(numberToHumanSize).toHaveBeenCalled(); - expect(wrapper.vm.blobSize).toBe(mockHumanReadableSize); + expect(wrapper.vm.blobSize).toBe('a lot'); }); it('renders a slot and prepends its contents to the existing one', () => { diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js index e2d2e4b73b3..4ca2ccba385 100644 --- a/spec/frontend/clusters_list/components/clusters_spec.js +++ b/spec/frontend/clusters_list/components/clusters_spec.js @@ -28,13 +28,17 @@ describe('Clusters', () => { return axios.waitForAll(); }; + const paginationHeader = (total = apiData.clusters.length, perPage = 20, currentPage = 1) => { + return { + 'x-total': total, + 'x-per-page': perPage, + 'x-page': currentPage, + }; + }; + beforeEach(() => { mock = new MockAdapter(axios); - mockPollingApi(200, apiData, { - 'x-total': apiData.clusters.length, - 'x-per-page': 20, - 'x-page': 1, - }); + mockPollingApi(200, apiData, paginationHeader()); return mountWrapper(); }); @@ -99,17 +103,30 @@ describe('Clusters', () => { }); }); + describe('nodes present', () => { + it.each` + nodeSize | lineNumber + ${'Unknown'} | ${0} + ${'1'} | ${1} + ${'2'} | ${2} + ${'Unknown'} | ${3} + ${'Unknown'} | ${4} + ${'Unknown'} | ${5} + `('renders node size for each cluster', ({ nodeSize, lineNumber }) => { + const sizes = findTable().findAll('td:nth-child(3)'); + const size = sizes.at(lineNumber); + + expect(size.text()).toBe(nodeSize); + }); + }); + describe('pagination', () => { const perPage = apiData.clusters.length; const totalFirstPage = 100; const totalSecondPage = 500; beforeEach(() => { - mockPollingApi(200, apiData, { - 'x-total': totalFirstPage, - 'x-per-page': perPage, - 'x-page': 1, - }); + mockPollingApi(200, apiData, paginationHeader(totalFirstPage, perPage, 1)); return mountWrapper(); }); @@ -123,11 +140,7 @@ describe('Clusters', () => { describe('when updating currentPage', () => { beforeEach(() => { - mockPollingApi(200, apiData, { - 'x-total': totalSecondPage, - 'x-per-page': perPage, - 'x-page': 2, - }); + mockPollingApi(200, apiData, paginationHeader(totalSecondPage, perPage, 2)); wrapper.setData({ currentPage: 2 }); return axios.waitForAll(); }); diff --git a/spec/frontend/clusters_list/mock_data.js b/spec/frontend/clusters_list/mock_data.js index 9a90a378f31..893061f86e8 100644 --- a/spec/frontend/clusters_list/mock_data.js +++ b/spec/frontend/clusters_list/mock_data.js @@ -1,57 +1,45 @@ export const clusterList = [ { name: 'My Cluster 1', - environmentScope: '*', - size: '3', - clusterType: 'group_type', + environment_scope: '*', + cluster_type: 'group_type', status: 'disabled', - cpu: '6 (100% free)', - memory: '22.50 (30% free)', + nodes: null, }, { name: 'My Cluster 2', - environmentScope: 'development', - size: '12', - clusterType: 'project_type', + environment_scope: 'development', + cluster_type: 'project_type', status: 'unreachable', - cpu: '3 (50% free)', - memory: '11 (60% free)', + nodes: [{ usage: { cpu: '246155922n', memory: '1255212Ki' } }], }, { name: 'My Cluster 3', - environmentScope: 'development', - size: '12', - clusterType: 'project_type', + environment_scope: 'development', + cluster_type: 'project_type', status: 'authentication_failure', - cpu: '1 (0% free)', - memory: '22 (33% free)', + nodes: [ + { usage: { cpu: '246155922n', memory: '1255212Ki' } }, + { usage: { cpu: '307051934n', memory: '1379136Ki' } }, + ], }, { name: 'My Cluster 4', - environmentScope: 'production', - size: '12', - clusterType: 'project_type', + environment_scope: 'production', + cluster_type: 'project_type', status: 'deleting', - cpu: '6 (100% free)', - memory: '45 (15% free)', }, { name: 'My Cluster 5', - environmentScope: 'development', - size: '12', - clusterType: 'project_type', + environment_scope: 'development', + cluster_type: 'project_type', status: 'created', - cpu: '6 (100% free)', - memory: '20.12 (35% free)', }, { name: 'My Cluster 6', - environmentScope: '*', - size: '1', - clusterType: 'project_type', + environment_scope: '*', + cluster_type: 'project_type', status: 'cleanup_ongoing', - cpu: '6 (100% free)', - memory: '20.12 (35% free)', }, ]; diff --git a/spec/frontend/clusters_list/store/actions_spec.js b/spec/frontend/clusters_list/store/actions_spec.js index 70766af3ec4..74e351a3704 100644 --- a/spec/frontend/clusters_list/store/actions_spec.js +++ b/spec/frontend/clusters_list/store/actions_spec.js @@ -1,10 +1,14 @@ import MockAdapter from 'axios-mock-adapter'; +import Poll from '~/lib/utils/poll'; import flashError from '~/flash'; import testAction from 'helpers/vuex_action_helper'; import axios from '~/lib/utils/axios_utils'; +import waitForPromises from 'helpers/wait_for_promises'; import { apiData } from '../mock_data'; +import { MAX_REQUESTS } from '~/clusters_list/constants'; import * as types from '~/clusters_list/store/mutation_types'; import * as actions from '~/clusters_list/store/actions'; +import * as Sentry from '@sentry/browser'; jest.mock('~/flash.js'); @@ -12,6 +16,24 @@ describe('Clusters store actions', () => { describe('fetchClusters', () => { let mock; + const headers = { + 'x-next-page': 1, + 'x-total': apiData.clusters.length, + 'x-total-pages': 1, + 'x-per-page': 20, + 'x-page': 1, + 'x-prev-page': 1, + }; + + const paginationInformation = { + nextPage: 1, + page: 1, + perPage: 20, + previousPage: 1, + total: apiData.clusters.length, + totalPages: 1, + }; + beforeEach(() => { mock = new MockAdapter(axios); }); @@ -19,21 +41,6 @@ describe('Clusters store actions', () => { afterEach(() => mock.restore()); it('should commit SET_CLUSTERS_DATA with received response', done => { - const headers = { - 'x-total': apiData.clusters.length, - 'x-per-page': 20, - 'x-page': 1, - }; - - const paginationInformation = { - nextPage: NaN, - page: 1, - perPage: 20, - previousPage: NaN, - total: apiData.clusters.length, - totalPages: NaN, - }; - mock.onGet().reply(200, apiData, headers); testAction( @@ -52,9 +59,110 @@ describe('Clusters store actions', () => { it('should show flash on API error', done => { mock.onGet().reply(400, 'Not Found'); - testAction(actions.fetchClusters, { endpoint: apiData.endpoint }, {}, [], [], () => { - expect(flashError).toHaveBeenCalledWith(expect.stringMatching('error')); - done(); + testAction( + actions.fetchClusters, + { endpoint: apiData.endpoint }, + {}, + [{ type: types.SET_LOADING_STATE, payload: false }], + [], + () => { + expect(flashError).toHaveBeenCalledWith(expect.stringMatching('error')); + done(); + }, + ); + }); + + describe('multiple api requests', () => { + let captureException; + let pollRequest; + let pollStop; + + const pollInterval = 10; + const pollHeaders = { 'poll-interval': pollInterval, ...headers }; + + beforeEach(() => { + captureException = jest.spyOn(Sentry, 'captureException'); + pollRequest = jest.spyOn(Poll.prototype, 'makeRequest'); + pollStop = jest.spyOn(Poll.prototype, 'stop'); + + mock.onGet().reply(200, apiData, pollHeaders); + }); + + afterEach(() => { + captureException.mockRestore(); + pollRequest.mockRestore(); + pollStop.mockRestore(); + }); + + it('should stop polling after MAX Requests', done => { + testAction( + actions.fetchClusters, + { endpoint: apiData.endpoint }, + {}, + [ + { type: types.SET_CLUSTERS_DATA, payload: { data: apiData, paginationInformation } }, + { type: types.SET_LOADING_STATE, payload: false }, + ], + [], + () => { + expect(pollRequest).toHaveBeenCalledTimes(1); + expect(pollStop).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(pollInterval); + + waitForPromises() + .then(() => { + expect(pollRequest).toHaveBeenCalledTimes(2); + expect(pollStop).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(pollInterval); + }) + .then(() => waitForPromises()) + .then(() => { + expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS); + expect(pollStop).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(pollInterval); + }) + .then(() => waitForPromises()) + .then(() => { + expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS + 1); + // Stops poll once it exceeds the MAX_REQUESTS limit + expect(pollStop).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(pollInterval); + }) + .then(() => waitForPromises()) + .then(() => { + // Additional poll requests are not made once pollStop is called + expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS + 1); + expect(pollStop).toHaveBeenCalledTimes(1); + }) + .then(done) + .catch(done.fail); + }, + ); + }); + + it('should stop polling and report to Sentry when data is invalid', done => { + const badApiResponse = { clusters: {} }; + mock.onGet().reply(200, badApiResponse, pollHeaders); + + testAction( + actions.fetchClusters, + { endpoint: apiData.endpoint }, + {}, + [ + { + type: types.SET_CLUSTERS_DATA, + payload: { data: badApiResponse, paginationInformation }, + }, + { type: types.SET_LOADING_STATE, payload: false }, + ], + [], + () => { + expect(pollRequest).toHaveBeenCalledTimes(1); + expect(pollStop).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledTimes(1); + done(); + }, + ); }); }); }); diff --git a/spec/frontend/helpers/dom_shims/index.js b/spec/frontend/helpers/dom_shims/index.js index ed1c708c444..d18bb94c107 100644 --- a/spec/frontend/helpers/dom_shims/index.js +++ b/spec/frontend/helpers/dom_shims/index.js @@ -4,6 +4,7 @@ import './element_scroll_to'; import './form_element'; import './get_client_rects'; import './inner_text'; +import './mutation_observer'; import './window_scroll_to'; import './scroll_by'; import './size_properties'; diff --git a/spec/frontend/helpers/dom_shims/mutation_observer.js b/spec/frontend/helpers/dom_shims/mutation_observer.js new file mode 100644 index 00000000000..68c494f19ea --- /dev/null +++ b/spec/frontend/helpers/dom_shims/mutation_observer.js @@ -0,0 +1,7 @@ +/* eslint-disable class-methods-use-this */ +class MutationObserverStub { + disconnect() {} + observe() {} +} + +global.MutationObserver = MutationObserverStub; diff --git a/spec/frontend/ide/components/commit_sidebar/form_spec.js b/spec/frontend/ide/components/commit_sidebar/form_spec.js index 129180bb46e..bd7cb842e98 100644 --- a/spec/frontend/ide/components/commit_sidebar/form_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/form_spec.js @@ -69,24 +69,19 @@ describe('IDE commit form', () => { }); }); - it('collapses if lastCommitMsg is set to empty and current view is not commit view', () => { + it('collapses if lastCommitMsg is set to empty and current view is not commit view', async () => { store.state.lastCommitMsg = 'abc'; store.state.currentActivityView = leftSidebarViews.edit.name; + await vm.$nextTick(); - return vm - .$nextTick() - .then(() => { - // if commit message is set, form is uncollapsed - expect(vm.isCompact).toBe(false); + // if commit message is set, form is uncollapsed + expect(vm.isCompact).toBe(false); - store.state.lastCommitMsg = ''; + store.state.lastCommitMsg = ''; + await vm.$nextTick(); - return vm.$nextTick(); - }) - .then(() => { - // collapsed when set to empty - expect(vm.isCompact).toBe(true); - }); + // collapsed when set to empty + expect(vm.isCompact).toBe(true); }); }); diff --git a/spec/frontend/ide/components/repo_commit_section_spec.js b/spec/frontend/ide/components/repo_commit_section_spec.js index 237be018807..d4e4e064a52 100644 --- a/spec/frontend/ide/components/repo_commit_section_spec.js +++ b/spec/frontend/ide/components/repo_commit_section_spec.js @@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'; import { createStore } from '~/ide/stores'; import router from '~/ide/ide_router'; import RepoCommitSection from '~/ide/components/repo_commit_section.vue'; +import EmptyState from '~/ide/components/commit_sidebar/empty_state.vue'; import { stageKeys } from '~/ide/constants'; import { file } from '../helpers'; @@ -63,7 +64,7 @@ describe('RepoCommitSection', () => { wrapper.destroy(); }); - describe('empty Stage', () => { + describe('empty state', () => { beforeEach(() => { store.state.noChangesStateSvgPath = TEST_NO_CHANGES_SVG; store.state.committedStateSvgPath = 'svg'; @@ -74,11 +75,16 @@ describe('RepoCommitSection', () => { it('renders no changes text', () => { expect( wrapper - .find('.js-empty-state') + .find(EmptyState) .text() .trim(), ).toContain('No changes'); - expect(wrapper.find('.js-empty-state img').attributes('src')).toBe(TEST_NO_CHANGES_SVG); + expect( + wrapper + .find(EmptyState) + .find('img') + .attributes('src'), + ).toBe(TEST_NO_CHANGES_SVG); }); }); @@ -109,6 +115,10 @@ describe('RepoCommitSection', () => { expect(changedFileNames).toEqual(allFiles.map(x => x.path)); }); + + it('does not show empty state', () => { + expect(wrapper.find(EmptyState).exists()).toBe(false); + }); }); describe('with unstaged file', () => { @@ -129,5 +139,9 @@ describe('RepoCommitSection', () => { keyPrefix: stageKeys.unstaged, }); }); + + it('does not show empty state', () => { + expect(wrapper.find(EmptyState).exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/ide/stores/actions/file_spec.js b/spec/frontend/ide/stores/actions/file_spec.js index 43cb06f5d92..e50697af5eb 100644 --- a/spec/frontend/ide/stores/actions/file_spec.js +++ b/spec/frontend/ide/stores/actions/file_spec.js @@ -587,20 +587,6 @@ describe('IDE store file actions', () => { }) .catch(done.fail); }); - - it('bursts unused seal', done => { - store - .dispatch('changeFileContent', { - path: tmpFile.path, - content: 'content', - }) - .then(() => { - expect(store.state.unusedSeal).toBe(false); - - done(); - }) - .catch(done.fail); - }); }); describe('with changed file', () => { diff --git a/spec/frontend/ide/stores/actions_spec.js b/spec/frontend/ide/stores/actions_spec.js index d52b0435906..666ed8a24aa 100644 --- a/spec/frontend/ide/stores/actions_spec.js +++ b/spec/frontend/ide/stores/actions_spec.js @@ -292,21 +292,6 @@ describe('Multi-file store actions', () => { }) .catch(done.fail); }); - - it('bursts unused seal', done => { - store - .dispatch('createTempEntry', { - name: 'test', - branchId: 'mybranch', - type: 'blob', - }) - .then(() => { - expect(store.state.unusedSeal).toBe(false); - - done(); - }) - .catch(done.fail); - }); }); }); @@ -682,19 +667,6 @@ describe('Multi-file store actions', () => { }); }); }); - - it('bursts unused seal', done => { - store.state.entries.test = file('test'); - - store - .dispatch('deleteEntry', 'test') - .then(() => { - expect(store.state.unusedSeal).toBe(false); - - done(); - }) - .catch(done.fail); - }); }); describe('renameEntry', () => { @@ -839,20 +811,6 @@ describe('Multi-file store actions', () => { .then(done) .catch(done.fail); }); - - it('bursts unused seal', done => { - store - .dispatch('renameEntry', { - path: 'orig', - name: 'renamed', - }) - .then(() => { - expect(store.state.unusedSeal).toBe(false); - - done(); - }) - .catch(done.fail); - }); }); describe('folder', () => { diff --git a/spec/frontend/ide/stores/mutations/file_spec.js b/spec/frontend/ide/stores/mutations/file_spec.js index 9b96b910fcb..cd308ee9991 100644 --- a/spec/frontend/ide/stores/mutations/file_spec.js +++ b/spec/frontend/ide/stores/mutations/file_spec.js @@ -356,14 +356,6 @@ describe('IDE store file mutations', () => { expect(localState.changedFiles.length).toBe(1); }); - - it('bursts unused seal', () => { - expect(localState.unusedSeal).toBe(true); - - mutations.ADD_FILE_TO_CHANGED(localState, localFile.path); - - expect(localState.unusedSeal).toBe(false); - }); }); describe('REMOVE_FILE_FROM_CHANGED', () => { @@ -374,14 +366,6 @@ describe('IDE store file mutations', () => { expect(localState.changedFiles.length).toBe(0); }); - - it('bursts unused seal', () => { - expect(localState.unusedSeal).toBe(true); - - mutations.REMOVE_FILE_FROM_CHANGED(localState, localFile.path); - - expect(localState.unusedSeal).toBe(false); - }); }); describe.each` @@ -533,19 +517,6 @@ describe('IDE store file mutations', () => { }, ); - describe('STAGE_CHANGE', () => { - it('bursts unused seal', () => { - expect(localState.unusedSeal).toBe(true); - - mutations.STAGE_CHANGE(localState, { - path: localFile.path, - diffInfo: localStore.getters.getDiffInfo(localFile.path), - }); - - expect(localState.unusedSeal).toBe(false); - }); - }); - describe('TOGGLE_FILE_CHANGED', () => { it('updates file changed status', () => { mutations.TOGGLE_FILE_CHANGED(localState, { diff --git a/spec/frontend/ide/stores/mutations_spec.js b/spec/frontend/ide/stores/mutations_spec.js index 2eca9acb8d8..55cc6eb66ab 100644 --- a/spec/frontend/ide/stores/mutations_spec.js +++ b/spec/frontend/ide/stores/mutations_spec.js @@ -265,16 +265,6 @@ describe('Multi-file store mutations', () => { expect(localState.changedFiles).toEqual([]); expect(localState.stagedFiles).toEqual([]); }); - - it('bursts unused seal', () => { - localState.entries.test = file('test'); - - expect(localState.unusedSeal).toBe(true); - - mutations.DELETE_ENTRY(localState, 'test'); - - expect(localState.unusedSeal).toBe(false); - }); }); describe('UPDATE_FILE_AFTER_COMMIT', () => { diff --git a/spec/frontend/monitoring/components/embeds/metric_embed_spec.js b/spec/frontend/monitoring/components/embeds/metric_embed_spec.js index f23823ccad6..4e7fee81d66 100644 --- a/spec/frontend/monitoring/components/embeds/metric_embed_spec.js +++ b/spec/frontend/monitoring/components/embeds/metric_embed_spec.js @@ -4,6 +4,7 @@ import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; import { TEST_HOST } from 'helpers/test_constants'; import MetricEmbed from '~/monitoring/components/embeds/metric_embed.vue'; import { groups, initialState, metricsData, metricsWithData } from './mock_data'; +import { setHTMLFixture } from 'helpers/fixtures'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -25,6 +26,8 @@ describe('MetricEmbed', () => { } beforeEach(() => { + setHTMLFixture('<div class="layout-page"></div>'); + actions = { setInitialState: jest.fn(), setShowErrorBanner: jest.fn(), diff --git a/spec/frontend/monitoring/store/getters_spec.js b/spec/frontend/monitoring/store/getters_spec.js index 9f17dda3b9f..c7d0fb119de 100644 --- a/spec/frontend/monitoring/store/getters_spec.js +++ b/spec/frontend/monitoring/store/getters_spec.js @@ -329,7 +329,7 @@ describe('Monitoring store Getters', () => { }); }); - describe('getCustomVariablesArray', () => { + describe('getCustomVariablesParams', () => { let state; beforeEach(() => { @@ -340,25 +340,21 @@ describe('Monitoring store Getters', () => { it('transforms the variables object to an array in the [variable, variable_value] format for all variable types', () => { mutations[types.SET_VARIABLES](state, mockTemplatingDataResponses.allVariableTypes); - const variablesArray = getters.getCustomVariablesArray(state); - - expect(variablesArray).toEqual([ - 'simpleText', - 'Simple text', - 'advText', - 'default', - 'simpleCustom', - 'value1', - 'advCustomNormal', - 'value2', - ]); + const variablesArray = getters.getCustomVariablesParams(state); + + expect(variablesArray).toEqual({ + 'variables[advCustomNormal]': 'value2', + 'variables[advText]': 'default', + 'variables[simpleCustom]': 'value1', + 'variables[simpleText]': 'Simple text', + }); }); it('transforms the variables object to an empty array when no keys are present', () => { mutations[types.SET_VARIABLES](state, {}); - const variablesArray = getters.getCustomVariablesArray(state); + const variablesArray = getters.getCustomVariablesParams(state); - expect(variablesArray).toEqual([]); + expect(variablesArray).toEqual({}); }); }); diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js index a9b06eab3fa..9731ce3f8a6 100644 --- a/spec/frontend/pipelines/graph/graph_component_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_spec.js @@ -7,6 +7,7 @@ import linkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines import graphJSON from './mock_data'; import linkedPipelineJSON from './linked_pipelines_mock_data'; import PipelinesMediator from '~/pipelines/pipeline_details_mediator'; +import { setHTMLFixture } from 'helpers/fixtures'; describe('graph component', () => { const store = new PipelineStore(); @@ -15,6 +16,10 @@ describe('graph component', () => { let wrapper; + beforeEach(() => { + setHTMLFixture('<div class="layout-page"></div>'); + }); + afterEach(() => { wrapper.destroy(); wrapper = null; diff --git a/spec/frontend/registry/explorer/components/__snapshots__/project_empty_state_spec.js.snap b/spec/frontend/registry/explorer/components/__snapshots__/project_empty_state_spec.js.snap index 19767aefd1a..d8ec9c3ca4d 100644 --- a/spec/frontend/registry/explorer/components/__snapshots__/project_empty_state_spec.js.snap +++ b/spec/frontend/registry/explorer/components/__snapshots__/project_empty_state_spec.js.snap @@ -19,7 +19,7 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = ` </p> <h5> - Quick Start + CLI Commands </h5> <p diff --git a/spec/frontend/registry/explorer/components/quickstart_dropdown_spec.js b/spec/frontend/registry/explorer/components/cli_commands_spec.js index 0c3baefbc58..6f1cbf9d255 100644 --- a/spec/frontend/registry/explorer/components/quickstart_dropdown_spec.js +++ b/spec/frontend/registry/explorer/components/cli_commands_spec.js @@ -3,7 +3,7 @@ import { mount, createLocalVue } from '@vue/test-utils'; import { GlDropdown, GlFormGroup, GlFormInputGroup } from '@gitlab/ui'; import Tracking from '~/tracking'; import * as getters from '~/registry/explorer/stores/getters'; -import QuickstartDropdown from '~/registry/explorer/components/quickstart_dropdown.vue'; +import QuickstartDropdown from '~/registry/explorer/components/cli_commands.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import { @@ -19,7 +19,7 @@ import { const localVue = createLocalVue(); localVue.use(Vuex); -describe('quickstart_dropdown', () => { +describe('cli_commands', () => { let wrapper; let store; diff --git a/spec/frontend/registry/explorer/components/project_policy_alert_spec.js b/spec/frontend/registry/explorer/components/project_policy_alert_spec.js deleted file mode 100644 index 89c37e55398..00000000000 --- a/spec/frontend/registry/explorer/components/project_policy_alert_spec.js +++ /dev/null @@ -1,132 +0,0 @@ -import Vuex from 'vuex'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { GlSprintf, GlAlert, GlLink } from '@gitlab/ui'; -import * as dateTimeUtils from '~/lib/utils/datetime_utility'; -import component from '~/registry/explorer/components/project_policy_alert.vue'; -import { - EXPIRATION_POLICY_ALERT_TITLE, - EXPIRATION_POLICY_ALERT_PRIMARY_BUTTON, -} from '~/registry/explorer/constants'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('Project Policy Alert', () => { - let wrapper; - let store; - - const defaultState = { - config: { - expirationPolicy: { - enabled: true, - }, - settingsPath: 'foo', - expirationPolicyHelpPagePath: 'bar', - }, - images: [], - isLoading: false, - }; - - const findAlert = () => wrapper.find(GlAlert); - const findLink = () => wrapper.find(GlLink); - - const createComponent = (state = defaultState) => { - store = new Vuex.Store({ - state, - }); - wrapper = shallowMount(component, { - localVue, - store, - stubs: { - GlSprintf, - }, - }); - }; - - const documentationExpectation = () => { - it('contain a documentation link', () => { - createComponent(); - expect(findLink().attributes('href')).toBe(defaultState.config.expirationPolicyHelpPagePath); - expect(findLink().text()).toBe('documentation'); - }); - }; - - beforeEach(() => { - jest.spyOn(dateTimeUtils, 'approximateDuration').mockReturnValue('1 day'); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('is hidden', () => { - it('when expiration policy does not exist', () => { - createComponent({ config: {} }); - expect(findAlert().exists()).toBe(false); - }); - - it('when expiration policy exist but is disabled', () => { - createComponent({ - ...defaultState, - config: { - expirationPolicy: { - enabled: false, - }, - }, - }); - expect(findAlert().exists()).toBe(false); - }); - }); - - describe('is visible', () => { - it('when expiration policy exists and is enabled', () => { - createComponent(); - expect(findAlert().exists()).toBe(true); - }); - }); - - describe('full info alert', () => { - beforeEach(() => { - createComponent({ ...defaultState, images: [1] }); - }); - - it('has a primary button', () => { - const alert = findAlert(); - expect(alert.props('primaryButtonText')).toBe(EXPIRATION_POLICY_ALERT_PRIMARY_BUTTON); - expect(alert.props('primaryButtonLink')).toBe(defaultState.config.settingsPath); - }); - - it('has a title', () => { - const alert = findAlert(); - expect(alert.props('title')).toBe(EXPIRATION_POLICY_ALERT_TITLE); - }); - - it('has the full message', () => { - expect(findAlert().html()).toContain('<strong>1 day</strong>'); - }); - - documentationExpectation(); - }); - - describe('compact info alert', () => { - beforeEach(() => { - createComponent({ ...defaultState, images: [] }); - }); - - it('does not have a button', () => { - const alert = findAlert(); - expect(alert.props('primaryButtonText')).toBe(null); - }); - - it('does not have a title', () => { - const alert = findAlert(); - expect(alert.props('title')).toBe(null); - }); - - it('has the short message', () => { - expect(findAlert().html()).not.toContain('<strong>1 day</strong>'); - }); - - documentationExpectation(); - }); -}); diff --git a/spec/frontend/registry/explorer/components/registry_header_spec.js b/spec/frontend/registry/explorer/components/registry_header_spec.js new file mode 100644 index 00000000000..9ce2b505e44 --- /dev/null +++ b/spec/frontend/registry/explorer/components/registry_header_spec.js @@ -0,0 +1,221 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlSprintf, GlLink } from '@gitlab/ui'; +import Component from '~/registry/explorer/components/registry_header.vue'; +import { + CONTAINER_REGISTRY_TITLE, + LIST_INTRO_TEXT, + EXPIRATION_POLICY_DISABLED_MESSAGE, + EXPIRATION_POLICY_DISABLED_TEXT, + EXPIRATION_POLICY_WILL_RUN_IN, +} from '~/registry/explorer/constants'; + +jest.mock('~/lib/utils/datetime_utility', () => ({ + approximateDuration: jest.fn(), + calculateRemainingMilliseconds: jest.fn(), +})); + +describe('registry_header', () => { + let wrapper; + + const findHeader = () => wrapper.find('[data-testid="header"]'); + const findTitle = () => wrapper.find('[data-testid="title"]'); + const findCommandsSlot = () => wrapper.find('[data-testid="commands-slot"]'); + const findInfoArea = () => wrapper.find('[data-testid="info-area"]'); + const findIntroText = () => wrapper.find('[data-testid="default-intro"]'); + const findSubHeader = () => wrapper.find('[data-testid="subheader"]'); + const findImagesCountSubHeader = () => wrapper.find('[data-testid="images-count"]'); + const findExpirationPolicySubHeader = () => wrapper.find('[data-testid="expiration-policy"]'); + const findDisabledExpirationPolicyMessage = () => + wrapper.find('[data-testid="expiration-disabled-message"]'); + + const mountComponent = (propsData, slots) => { + wrapper = shallowMount(Component, { + stubs: { + GlSprintf, + }, + propsData, + slots, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('header', () => { + it('exists', () => { + mountComponent(); + expect(findHeader().exists()).toBe(true); + }); + + it('contains the title of the page', () => { + mountComponent(); + const title = findTitle(); + expect(title.exists()).toBe(true); + expect(title.text()).toBe(CONTAINER_REGISTRY_TITLE); + }); + + it('has a commands slot', () => { + mountComponent(null, { commands: 'baz' }); + expect(findCommandsSlot().text()).toBe('baz'); + }); + }); + + describe('subheader', () => { + describe('when there are no images', () => { + it('is hidden ', () => { + mountComponent(); + expect(findSubHeader().exists()).toBe(false); + }); + }); + + describe('when there are images', () => { + it('is visible', () => { + mountComponent({ imagesCount: 1 }); + expect(findSubHeader().exists()).toBe(true); + }); + + describe('sub header parts', () => { + describe('images count', () => { + it('exists', () => { + mountComponent({ imagesCount: 1 }); + expect(findImagesCountSubHeader().exists()).toBe(true); + }); + + it('when there is one image', () => { + mountComponent({ imagesCount: 1 }); + expect(findImagesCountSubHeader().text()).toMatchInterpolatedText('1 Image repository'); + }); + + it('when there is more than one image', () => { + mountComponent({ imagesCount: 3 }); + expect(findImagesCountSubHeader().text()).toMatchInterpolatedText( + '3 Image repositories', + ); + }); + }); + + describe('expiration policy', () => { + it('when is disabled', () => { + mountComponent({ + expirationPolicy: { enabled: false }, + expirationPolicyHelpPagePath: 'foo', + imagesCount: 1, + }); + const text = findExpirationPolicySubHeader(); + expect(text.exists()).toBe(true); + expect(text.text()).toMatchInterpolatedText(EXPIRATION_POLICY_DISABLED_TEXT); + }); + + it('when is enabled', () => { + mountComponent({ + expirationPolicy: { enabled: true }, + expirationPolicyHelpPagePath: 'foo', + imagesCount: 1, + }); + const text = findExpirationPolicySubHeader(); + expect(text.exists()).toBe(true); + expect(text.text()).toMatchInterpolatedText(EXPIRATION_POLICY_WILL_RUN_IN); + }); + it('when the expiration policy is completely disabled', () => { + mountComponent({ + expirationPolicy: { enabled: true }, + expirationPolicyHelpPagePath: 'foo', + imagesCount: 1, + hideExpirationPolicyData: true, + }); + const text = findExpirationPolicySubHeader(); + expect(text.exists()).toBe(false); + }); + }); + }); + }); + }); + + describe('info area', () => { + it('exists', () => { + mountComponent(); + expect(findInfoArea().exists()).toBe(true); + }); + + describe('default message', () => { + beforeEach(() => { + mountComponent({ helpPagePath: 'bar' }); + }); + + it('exists', () => { + expect(findIntroText().exists()).toBe(true); + }); + + it('has the correct copy', () => { + expect(findIntroText().text()).toMatchInterpolatedText(LIST_INTRO_TEXT); + }); + + it('has the correct link', () => { + expect( + findIntroText() + .find(GlLink) + .attributes('href'), + ).toBe('bar'); + }); + }); + + describe('expiration policy info message', () => { + describe('when there are no images', () => { + it('is hidden', () => { + mountComponent(); + expect(findDisabledExpirationPolicyMessage().exists()).toBe(false); + }); + }); + + describe('when there are images', () => { + describe('when expiration policy is disabled', () => { + beforeEach(() => { + mountComponent({ + expirationPolicy: { enabled: false }, + expirationPolicyHelpPagePath: 'foo', + imagesCount: 1, + }); + }); + it('message exist', () => { + expect(findDisabledExpirationPolicyMessage().exists()).toBe(true); + }); + it('has the correct copy', () => { + expect(findDisabledExpirationPolicyMessage().text()).toMatchInterpolatedText( + EXPIRATION_POLICY_DISABLED_MESSAGE, + ); + }); + + it('has the correct link', () => { + expect( + findDisabledExpirationPolicyMessage() + .find(GlLink) + .attributes('href'), + ).toBe('foo'); + }); + }); + + describe('when expiration policy is enabled', () => { + it('message does not exist', () => { + mountComponent({ + expirationPolicy: { enabled: true }, + imagesCount: 1, + }); + expect(findDisabledExpirationPolicyMessage().exists()).toBe(false); + }); + }); + describe('when the expiration policy is completely disabled', () => { + it('message does not exist', () => { + mountComponent({ + expirationPolicy: { enabled: true }, + imagesCount: 1, + hideExpirationPolicyData: true, + }); + expect(findDisabledExpirationPolicyMessage().exists()).toBe(false); + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/registry/explorer/pages/list_spec.js b/spec/frontend/registry/explorer/pages/list_spec.js index 97742b9e9b3..c8d5e5bbd3c 100644 --- a/spec/frontend/registry/explorer/pages/list_spec.js +++ b/spec/frontend/registry/explorer/pages/list_spec.js @@ -3,10 +3,10 @@ import { GlSkeletonLoader, GlSprintf, GlAlert, GlSearchBoxByClick } from '@gitla import Tracking from '~/tracking'; import waitForPromises from 'helpers/wait_for_promises'; import component from '~/registry/explorer/pages/list.vue'; -import QuickstartDropdown from '~/registry/explorer/components/quickstart_dropdown.vue'; +import CliCommands from '~/registry/explorer/components/cli_commands.vue'; import GroupEmptyState from '~/registry/explorer/components/group_empty_state.vue'; import ProjectEmptyState from '~/registry/explorer/components/project_empty_state.vue'; -import ProjectPolicyAlert from '~/registry/explorer/components/project_policy_alert.vue'; +import RegistryHeader from '~/registry/explorer/components/registry_header.vue'; import ImageList from '~/registry/explorer/components/image_list.vue'; import { createStore } from '~/registry/explorer/stores/'; import { @@ -32,14 +32,14 @@ describe('List Page', () => { const findDeleteModal = () => wrapper.find(GlModal); const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader); - const findImagesList = () => wrapper.find({ ref: 'imagesList' }); const findEmptyState = () => wrapper.find(GlEmptyState); - const findQuickStartDropdown = () => wrapper.find(QuickstartDropdown); + const findCliCommands = () => wrapper.find(CliCommands); const findProjectEmptyState = () => wrapper.find(ProjectEmptyState); const findGroupEmptyState = () => wrapper.find(GroupEmptyState); - const findProjectPolicyAlert = () => wrapper.find(ProjectPolicyAlert); + const findRegistryHeader = () => wrapper.find(RegistryHeader); + const findDeleteAlert = () => wrapper.find(GlAlert); const findImageList = () => wrapper.find(ImageList); const findListHeader = () => wrapper.find('[data-testid="listHeader"]'); @@ -53,6 +53,7 @@ describe('List Page', () => { GlModal, GlEmptyState, GlSprintf, + RegistryHeader, }, mocks: { $toast, @@ -76,21 +77,6 @@ describe('List Page', () => { wrapper.destroy(); }); - describe('Expiration policy notification', () => { - beforeEach(() => { - mountComponent(); - }); - it('shows up on project page', () => { - expect(findProjectPolicyAlert().exists()).toBe(true); - }); - it('does show up on group page', () => { - store.commit(SET_INITIAL_STATE, { isGroupPage: true }); - return wrapper.vm.$nextTick().then(() => { - expect(findProjectPolicyAlert().exists()).toBe(false); - }); - }); - }); - describe('API calls', () => { it.each` imageList | name | called @@ -109,6 +95,11 @@ describe('List Page', () => { ); }); + it('contains registry header', () => { + mountComponent(); + expect(findRegistryHeader().exists()).toBe(true); + }); + describe('connection error', () => { const config = { characterError: true, @@ -139,7 +130,7 @@ describe('List Page', () => { it('should not show the loading or default state', () => { expect(findSkeletonLoader().exists()).toBe(false); - expect(findImagesList().exists()).toBe(false); + expect(findImageList().exists()).toBe(false); }); }); @@ -156,11 +147,11 @@ describe('List Page', () => { }); it('imagesList is not visible', () => { - expect(findImagesList().exists()).toBe(false); + expect(findImageList().exists()).toBe(false); }); - it('quick start is not visible', () => { - expect(findQuickStartDropdown().exists()).toBe(false); + it('cli commands is not visible', () => { + expect(findCliCommands().exists()).toBe(false); }); }); @@ -171,8 +162,8 @@ describe('List Page', () => { return waitForPromises(); }); - it('quick start is not visible', () => { - expect(findQuickStartDropdown().exists()).toBe(false); + it('cli commands is not visible', () => { + expect(findCliCommands().exists()).toBe(false); }); it('project empty state is visible', () => { @@ -193,8 +184,8 @@ describe('List Page', () => { expect(findGroupEmptyState().exists()).toBe(true); }); - it('quick start is not visible', () => { - expect(findQuickStartDropdown().exists()).toBe(false); + it('cli commands is not visible', () => { + expect(findCliCommands().exists()).toBe(false); }); it('list header is not visible', () => { @@ -210,7 +201,7 @@ describe('List Page', () => { }); it('quick start is visible', () => { - expect(findQuickStartDropdown().exists()).toBe(true); + expect(findCliCommands().exists()).toBe(true); }); it('list component is visible', () => { diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js index e216f49630f..49eae715a45 100644 --- a/spec/frontend/test_setup.js +++ b/spec/frontend/test_setup.js @@ -43,15 +43,6 @@ Object.assign(global, { preloadFixtures() {}, }); -Object.assign(global, { - MutationObserver() { - return { - disconnect() {}, - observe() {}, - }; - }, -}); - // custom-jquery-matchers was written for an old Jest version, we need to make it compatible Object.entries(jqueryMatchers).forEach(([matcherName, matcherFactory]) => { // Don't override existing Jest matcher @@ -69,12 +60,6 @@ expect.extend(customMatchers); // Tech debt issue TBD testUtilsConfig.logModifiedComponents = false; -// Basic stub for MutationObserver -global.MutationObserver = () => ({ - disconnect: () => {}, - observe: () => {}, -}); - Object.assign(global, { requestIdleCallback(cb) { const start = Date.now(); 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 new file mode 100644 index 00000000000..4d223efe9b4 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js @@ -0,0 +1,47 @@ +import { + generateToolbarItem, + addCustomEventListener, +} from '~/vue_shared/components/rich_content_editor/editor_service'; + +describe('Editor Service', () => { + describe('generateToolbarItem', () => { + const config = { + icon: 'bold', + command: 'some-command', + tooltip: 'Some Tooltip', + event: 'some-event', + }; + const generatedItem = generateToolbarItem(config); + + it('generates the correct command', () => { + expect(generatedItem.options.command).toBe(config.command); + }); + + it('generates the correct tooltip', () => { + expect(generatedItem.options.tooltip).toBe(config.tooltip); + }); + + it('generates the correct event', () => { + expect(generatedItem.options.event).toBe(config.event); + }); + + it('generates a divider when isDivider is set to true', () => { + const isDivider = true; + + expect(generateToolbarItem({ isDivider })).toBe('divider'); + }); + }); + + describe('addCustomEventListener', () => { + const mockInstance = { eventManager: { addEventType: jest.fn(), listen: jest.fn() } }; + const event = 'someCustomEvent'; + const handler = jest.fn(); + + it('registers an event type on the instance and adds an event handler', () => { + addCustomEventListener(mockInstance, event, handler); + + expect(mockInstance.eventManager.addEventType).toHaveBeenCalledWith(event); + expect(mockInstance.eventManager.listen).toHaveBeenCalledWith(event, handler); + }); + }); +}); 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 549d89171c6..fc9c3424db3 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 @@ -5,8 +5,16 @@ import { EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE, + CUSTOM_EVENTS, } from '~/vue_shared/components/rich_content_editor/constants'; +import { addCustomEventListener } from '~/vue_shared/components/rich_content_editor/editor_service'; + +jest.mock('~/vue_shared/components/rich_content_editor/editor_service', () => ({ + ...jest.requireActual('~/vue_shared/components/rich_content_editor/editor_service'), + addCustomEventListener: jest.fn(), +})); + describe('Rich Content Editor', () => { let wrapper; @@ -56,4 +64,17 @@ describe('Rich Content Editor', () => { expect(wrapper.emitted().input[0][0]).toBe(changedMarkdown); }); }); + + describe('when editor is loaded', () => { + it('adds the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { + const mockInstance = { eventManager: { addEventType: jest.fn(), listen: jest.fn() } }; + findEditor().vm.$emit('load', mockInstance); + + expect(addCustomEventListener).toHaveBeenCalledWith( + mockInstance, + CUSTOM_EVENTS.openAddImageModal, + wrapper.vm.onOpenAddImageModal, + ); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/toolbar_service_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/toolbar_service_spec.js deleted file mode 100644 index 7605cc6a22c..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/toolbar_service_spec.js +++ /dev/null @@ -1,29 +0,0 @@ -import { generateToolbarItem } from '~/vue_shared/components/rich_content_editor/toolbar_service'; - -describe('Toolbar Service', () => { - const config = { - icon: 'bold', - command: 'some-command', - tooltip: 'Some Tooltip', - event: 'some-event', - }; - const generatedItem = generateToolbarItem(config); - - it('generates the correct command', () => { - expect(generatedItem.options.command).toBe(config.command); - }); - - it('generates the correct tooltip', () => { - expect(generatedItem.options.tooltip).toBe(config.tooltip); - }); - - it('generates the correct event', () => { - expect(generatedItem.options.event).toBe(config.event); - }); - - it('generates a divider when isDivider is set to true', () => { - const isDivider = true; - - expect(generateToolbarItem({ isDivider })).toBe('divider'); - }); -}); diff --git a/spec/helpers/milestones_helper_spec.rb b/spec/helpers/timeboxes_helper_spec.rb index 4ce7143bdf0..6fe738914ce 100644 --- a/spec/helpers/milestones_helper_spec.rb +++ b/spec/helpers/timeboxes_helper_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe MilestonesHelper do +describe TimeboxesHelper do describe '#milestones_filter_dropdown_path' do let(:project) { create(:project) } let(:project2) { create(:project) } @@ -39,23 +39,34 @@ describe MilestonesHelper do end end - describe "#milestone_date_range" do - def result_for(*args) - milestone_date_range(build(:milestone, *args)) - end - + describe "#timebox_date_range" do let(:yesterday) { Date.yesterday } let(:tomorrow) { yesterday + 2 } let(:format) { '%b %-d, %Y' } let(:yesterday_formatted) { yesterday.strftime(format) } let(:tomorrow_formatted) { tomorrow.strftime(format) } - it { expect(result_for(due_date: nil, start_date: nil)).to be_nil } - it { expect(result_for(due_date: tomorrow)).to eq("expires on #{tomorrow_formatted}") } - it { expect(result_for(due_date: yesterday)).to eq("expired on #{yesterday_formatted}") } - it { expect(result_for(start_date: tomorrow)).to eq("starts on #{tomorrow_formatted}") } - it { expect(result_for(start_date: yesterday)).to eq("started on #{yesterday_formatted}") } - it { expect(result_for(start_date: yesterday, due_date: tomorrow)).to eq("#{yesterday_formatted}–#{tomorrow_formatted}") } + context 'milestone' do + def result_for(*args) + timebox_date_range(build(:milestone, *args)) + end + + it { expect(result_for(due_date: nil, start_date: nil)).to be_nil } + it { expect(result_for(due_date: tomorrow)).to eq("expires on #{tomorrow_formatted}") } + it { expect(result_for(due_date: yesterday)).to eq("expired on #{yesterday_formatted}") } + it { expect(result_for(start_date: tomorrow)).to eq("starts on #{tomorrow_formatted}") } + it { expect(result_for(start_date: yesterday)).to eq("started on #{yesterday_formatted}") } + it { expect(result_for(start_date: yesterday, due_date: tomorrow)).to eq("#{yesterday_formatted}–#{tomorrow_formatted}") } + end + + context 'iteration' do + # Iterations always have start and due dates, so only A-B format is expected + it 'formats properly' do + iteration = build(:iteration, start_date: yesterday, due_date: tomorrow) + + expect(timebox_date_range(iteration)).to eq("#{yesterday_formatted}–#{tomorrow_formatted}") + end + end end describe '#milestone_counts' do diff --git a/spec/lib/gitlab/issuable_metadata_spec.rb b/spec/lib/gitlab/issuable_metadata_spec.rb index 7632bc3060a..1920cecfc29 100644 --- a/spec/lib/gitlab/issuable_metadata_spec.rb +++ b/spec/lib/gitlab/issuable_metadata_spec.rb @@ -6,14 +6,12 @@ describe Gitlab::IssuableMetadata do let(:user) { create(:user) } let!(:project) { create(:project, :public, :repository, creator: user, namespace: user.namespace) } - subject { Class.new { include Gitlab::IssuableMetadata }.new } - it 'returns an empty Hash if an empty collection is provided' do - expect(subject.issuable_meta_data(Issue.none, 'Issue', user)).to eq({}) + expect(described_class.new(user, Issue.none).data).to eq({}) end it 'raises an error when given a collection with no limit' do - expect { subject.issuable_meta_data(Issue.all, 'Issue', user) }.to raise_error(/must have a limit/) + expect { described_class.new(user, Issue.all) }.to raise_error(/must have a limit/) end context 'issues' do @@ -25,7 +23,7 @@ describe Gitlab::IssuableMetadata do let!(:closing_issues) { create(:merge_requests_closing_issues, issue: issue, merge_request: merge_request) } it 'aggregates stats on issues' do - data = subject.issuable_meta_data(Issue.all.limit(10), 'Issue', user) + data = described_class.new(user, Issue.all.limit(10)).data expect(data.count).to eq(2) expect(data[issue.id].upvotes).to eq(1) @@ -48,7 +46,7 @@ describe Gitlab::IssuableMetadata do let!(:note) { create(:note_on_merge_request, author: user, project: project, noteable: merge_request, note: "a comment on a MR") } it 'aggregates stats on merge requests' do - data = subject.issuable_meta_data(MergeRequest.all.limit(10), 'MergeRequest', user) + data = described_class.new(user, MergeRequest.all.limit(10)).data expect(data.count).to eq(2) expect(data[merge_request.id].upvotes).to eq(1) diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 1a4f1123c73..8b99cc41a53 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -1726,4 +1726,59 @@ describe Notify do is_expected.to have_body_text target_url end end + + describe 'merge request reviews' do + let!(:review) { create(:review, project: project, merge_request: merge_request) } + let!(:notes) { create_list(:note, 3, review: review, project: project, author: review.author, noteable: merge_request) } + + subject { described_class.new_review_email(recipient.id, review.id) } + + it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do + let(:model) { review.merge_request } + end + + it_behaves_like 'it should show Gmail Actions View Merge request link' + it_behaves_like 'an unsubscribeable thread' + + it 'is sent to the given recipient as the author' do + sender = subject.header[:from].addrs[0] + + aggregate_failures do + expect(sender.display_name).to eq(review.author_name) + expect(sender.address).to eq(gitlab_sender) + expect(subject).to deliver_to(recipient.notification_email) + end + end + + it 'contains the message from the notes of the review' do + review.notes.each do |note| + is_expected.to have_body_text note.note + end + end + + context 'when diff note' do + let!(:notes) { create_list(:diff_note_on_merge_request, 3, review: review, project: project, author: review.author, noteable: merge_request) } + + it 'links to notes' do + review.notes.each do |note| + # Text part + expect(subject.text_part.body.raw_source).to include( + project_merge_request_url(project, merge_request, anchor: "note_#{note.id}") + ) + end + end + end + + it 'contains review author name' do + is_expected.to have_body_text review.author_name + end + + it 'has the correct subject and body' do + aggregate_failures do + is_expected.to have_subject "Re: #{project.name} | #{merge_request.title} (#{merge_request.to_reference})" + + is_expected.to have_body_text project_merge_request_path(project, merge_request) + end + end + end end diff --git a/spec/migrations/backfill_status_page_published_incidents_spec.rb b/spec/migrations/backfill_status_page_published_incidents_spec.rb new file mode 100644 index 00000000000..ccdc8be4168 --- /dev/null +++ b/spec/migrations/backfill_status_page_published_incidents_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20200421195234_backfill_status_page_published_incidents.rb') + +describe BackfillStatusPagePublishedIncidents, :migration do + subject(:migration) { described_class.new } + + describe '#up' do + let(:projects) { table(:projects) } + let(:status_page_settings) { table(:status_page_settings) } + let(:issues) { table(:issues) } + let(:incidents) { table(:status_page_published_incidents) } + + let(:namespace) { table(:namespaces).create!(name: 'gitlab', path: 'gitlab') } + let(:project_without_status_page) { projects.create!(namespace_id: namespace.id) } + let(:enabled_project) { projects.create!(namespace_id: namespace.id) } + let(:disabled_project) { projects.create!(namespace_id: namespace.id) } + + let!(:enabled_setting) { status_page_settings.create!(enabled: true, project_id: enabled_project.id, **status_page_setting_attrs) } + let!(:disabled_setting) { status_page_settings.create!(enabled: false, project_id: disabled_project.id, **status_page_setting_attrs) } + + let!(:published_issue) { issues.create!(confidential: false, project_id: enabled_project.id) } + let!(:nonpublished_issue_1) { issues.create!(confidential: true, project_id: enabled_project.id) } + let!(:nonpublished_issue_2) { issues.create!(confidential: false, project_id: disabled_project.id) } + let!(:nonpublished_issue_3) { issues.create!(confidential: false, project_id: project_without_status_page.id) } + + let(:current_time) { Time.current.change(usec: 0) } + let(:status_page_setting_attrs) do + { + aws_s3_bucket_name: 'bucket', + aws_region: 'region', + aws_access_key: 'key', + encrypted_aws_secret_key: 'abc123', + encrypted_aws_secret_key_iv: 'abc123' + } + end + + it 'creates a StatusPage::PublishedIncident record for each published issue' do + Timecop.freeze(current_time) do + expect(incidents.all).to be_empty + + migrate! + + incident = incidents.first + + expect(incidents.count).to eq(1) + expect(incident.issue_id).to eq(published_issue.id) + expect(incident.created_at).to eq(current_time) + expect(incident.updated_at).to eq(current_time) + end + end + end +end diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index f741d2d9acf..296240b1602 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -263,7 +263,7 @@ describe Ci::Runner do subject { described_class.online } before do - @runner1 = create(:ci_runner, :instance, contacted_at: 1.hour.ago) + @runner1 = create(:ci_runner, :instance, contacted_at: 2.hours.ago) @runner2 = create(:ci_runner, :instance, contacted_at: 1.second.ago) end @@ -344,7 +344,7 @@ describe Ci::Runner do subject { described_class.offline } before do - @runner1 = create(:ci_runner, :instance, contacted_at: 1.hour.ago) + @runner1 = create(:ci_runner, :instance, contacted_at: 2.hours.ago) @runner2 = create(:ci_runner, :instance, contacted_at: 1.second.ago) end @@ -598,10 +598,10 @@ describe Ci::Runner do end end - describe '#update_cached_info' do + describe '#heartbeat' do let(:runner) { create(:ci_runner, :project) } - subject { runner.update_cached_info(architecture: '18-bit') } + subject { runner.heartbeat(architecture: '18-bit') } context 'when database was updated recently' do before do diff --git a/spec/models/concerns/limitable_spec.rb b/spec/models/concerns/limitable_spec.rb new file mode 100644 index 00000000000..ca0a257be7a --- /dev/null +++ b/spec/models/concerns/limitable_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Limitable do + let(:minimal_test_class) do + Class.new do + include ActiveModel::Model + + def self.name + 'TestClass' + end + + include Limitable + end + end + + before do + stub_const("MinimalTestClass", minimal_test_class) + end + + it { expect(MinimalTestClass.limit_name).to eq('test_classes') } + + context 'with scoped limit' do + before do + MinimalTestClass.limit_scope = :project + end + + it { expect(MinimalTestClass.limit_scope).to eq(:project) } + + it 'triggers scoped validations' do + instance = MinimalTestClass.new + + expect(instance).to receive(:validate_scoped_plan_limit_not_exceeded) + + instance.valid?(:create) + end + end + + context 'with global limit' do + before do + MinimalTestClass.limit_scope = Limitable::GLOBAL_SCOPE + end + + it { expect(MinimalTestClass.limit_scope).to eq(Limitable::GLOBAL_SCOPE) } + + it 'triggers scoped validations' do + instance = MinimalTestClass.new + + expect(instance).to receive(:validate_global_plan_limit_not_exceeded) + + instance.valid?(:create) + end + end +end diff --git a/spec/models/iteration_spec.rb b/spec/models/iteration_spec.rb index 62a5d9a7cf9..ae14adf9106 100644 --- a/spec/models/iteration_spec.rb +++ b/spec/models/iteration_spec.rb @@ -46,10 +46,7 @@ describe Iteration do end context 'when dates overlap' do - let(:start_date) { 5.days.from_now } - let(:due_date) { 6.days.from_now } - - shared_examples_for 'overlapping dates' do + context 'same group' do context 'when start_date is in range' do let(:start_date) { 5.days.from_now } let(:due_date) { 3.weeks.from_now } @@ -58,11 +55,6 @@ describe Iteration do expect(subject).not_to be_valid expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations') end - - it 'is not valid even if forced' do - subject.validate # to generate iid/etc - expect { subject.save(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/) - end end context 'when end_date is in range' do @@ -73,84 +65,25 @@ describe Iteration do expect(subject).not_to be_valid expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations') end - - it 'is not valid even if forced' do - subject.validate # to generate iid/etc - expect { subject.save(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/) - end end context 'when both overlap' do + let(:start_date) { 5.days.from_now } + let(:due_date) { 6.days.from_now } + it 'is not valid' do expect(subject).not_to be_valid expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations') end - - it 'is not valid even if forced' do - subject.validate # to generate iid/etc - expect { subject.save(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/) - end end end - context 'group' do - it_behaves_like 'overlapping dates' do - let(:constraint_name) { 'iteration_start_and_due_daterange_group_id_constraint' } - end - - context 'different group' do - let(:group) { create(:group) } - - it { is_expected.to be_valid } - - it 'does not trigger exclusion constraints' do - expect { subject.save }.not_to raise_exception - end - end - - context 'in a project' do - let(:project) { create(:project) } - - subject { build(:iteration, project: project, start_date: start_date, due_date: due_date) } - - it { is_expected.to be_valid } + context 'different group' do + let(:start_date) { 5.days.from_now } + let(:due_date) { 6.days.from_now } + let(:group) { create(:group) } - it 'does not trigger exclusion constraints' do - expect { subject.save }.not_to raise_exception - end - end - end - - context 'project' do - let_it_be(:existing_iteration) { create(:iteration, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) } - - subject { build(:iteration, project: project, start_date: start_date, due_date: due_date) } - - it_behaves_like 'overlapping dates' do - let(:constraint_name) { 'iteration_start_and_due_daterange_project_id_constraint' } - end - - context 'different project' do - let(:project) { create(:project) } - - it { is_expected.to be_valid } - - it 'does not trigger exclusion constraints' do - expect { subject.save }.not_to raise_exception - end - end - - context 'in a group' do - let(:group) { create(:group) } - - subject { build(:iteration, group: group, start_date: start_date, due_date: due_date) } - - it { is_expected.to be_valid } - - it 'does not trigger exclusion constraints' do - expect { subject.save }.not_to raise_exception - end - end + it { is_expected.to be_valid } end end end diff --git a/spec/requests/api/graphql/mutations/snippets/create_spec.rb b/spec/requests/api/graphql/mutations/snippets/create_spec.rb index e1e5fe22887..9052f54b171 100644 --- a/spec/requests/api/graphql/mutations/snippets/create_spec.rb +++ b/spec/requests/api/graphql/mutations/snippets/create_spec.rb @@ -109,31 +109,21 @@ describe 'Creating a Snippet' do context 'when the project path is invalid' do let(:project_path) { 'foobar' } - it 'returns an an error' do - subject - errors = json_response['errors'] - - expect(errors.first['message']).to eq(Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR) - end + it_behaves_like 'a mutation that returns top-level errors', + errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR] end context 'when the feature is disabled' do - it 'returns an an error' do + before do project.project_feature.update_attribute(:snippets_access_level, ProjectFeature::DISABLED) - - subject - errors = json_response['errors'] - - expect(errors.first['message']).to eq(Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR) end + + it_behaves_like 'a mutation that returns top-level errors', + errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR] end end - context 'when there are ActiveRecord validation errors' do - let(:title) { '' } - - it_behaves_like 'a mutation that returns errors in the response', errors: ["Title can't be blank"] - + shared_examples 'does not create snippet' do it 'does not create the Snippet' do expect do subject @@ -147,7 +137,21 @@ describe 'Creating a Snippet' do end end - context 'when there uploaded files' do + context 'when there are ActiveRecord validation errors' do + let(:title) { '' } + + it_behaves_like 'a mutation that returns errors in the response', errors: ["Title can't be blank"] + it_behaves_like 'does not create snippet' + end + + context 'when there non ActiveRecord errors' do + let(:file_name) { 'invalid://file/path' } + + it_behaves_like 'a mutation that returns errors in the response', errors: ['Repository Error creating the snippet - Invalid file name'] + it_behaves_like 'does not create snippet' + end + + context 'when there are uploaded files' do shared_examples 'expected files argument' do |file_value, expected_value| let(:uploaded_files) { file_value } diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb index 913dcb63d6e..6d4495a255d 100644 --- a/spec/requests/api/runner_spec.rb +++ b/spec/requests/api/runner_spec.rb @@ -1129,6 +1129,10 @@ describe API::Runner, :clean_gitlab_redis_shared_state do let(:send_request) { update_job(state: 'success') } end + it 'updates runner info' do + expect { update_job(state: 'success') }.to change { runner.reload.contacted_at } + end + context 'when status is given' do it 'mark job as succeeded' do update_job(state: 'success') @@ -1294,6 +1298,12 @@ describe API::Runner, :clean_gitlab_redis_shared_state do let(:send_request) { patch_the_trace } end + it 'updates runner info' do + runner.update!(contacted_at: 1.year.ago) + + expect { patch_the_trace }.to change { runner.reload.contacted_at } + end + context 'when request is valid' do it 'gets correct response' do expect(response).to have_gitlab_http_status(:accepted) @@ -1555,6 +1565,10 @@ describe API::Runner, :clean_gitlab_redis_shared_state do let(:send_request) { subject } end + it 'updates runner info' do + expect { subject }.to change { runner.reload.contacted_at } + end + shared_examples 'authorizes local file' do it 'succeeds' do subject @@ -1743,6 +1757,10 @@ describe API::Runner, :clean_gitlab_redis_shared_state do end end + it 'updates runner info' do + expect { upload_artifacts(file_upload, headers_with_token) }.to change { runner.reload.contacted_at } + end + context 'when artifacts are being stored inside of tmp path' do before do # by configuring this path we allow to pass temp file from any path @@ -2228,6 +2246,10 @@ describe API::Runner, :clean_gitlab_redis_shared_state do let(:send_request) { download_artifact } end + it 'updates runner info' do + expect { download_artifact }.to change { runner.reload.contacted_at } + end + context 'when job has artifacts' do let(:job) { create(:ci_build) } let(:store) { JobArtifactUploader::Store::LOCAL } diff --git a/spec/services/draft_notes/create_service_spec.rb b/spec/services/draft_notes/create_service_spec.rb new file mode 100644 index 00000000000..8f244ed386b --- /dev/null +++ b/spec/services/draft_notes/create_service_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe DraftNotes::CreateService do + let(:merge_request) { create(:merge_request) } + let(:project) { merge_request.target_project } + let(:user) { merge_request.author } + + def create_draft(params) + described_class.new(merge_request, user, params).execute + end + + it 'creates a simple draft note' do + draft = create_draft(note: 'This is a test') + + expect(draft).to be_an_instance_of(DraftNote) + expect(draft.note).to eq('This is a test') + expect(draft.author).to eq(user) + expect(draft.project).to eq(merge_request.target_project) + expect(draft.discussion_id).to be_nil + end + + it 'cannot resolve when there is nothing to resolve' do + draft = create_draft(note: 'Not a reply!', resolve_discussion: true) + + expect(draft.errors[:base]).to include('User is not allowed to resolve thread') + expect(draft).not_to be_persisted + end + + context 'in a thread' do + it 'creates a draft note with discussion_id' do + discussion = create(:discussion_note_on_merge_request, noteable: merge_request, project: project).discussion + + draft = create_draft(note: 'A reply!', in_reply_to_discussion_id: discussion.reply_id) + + expect(draft.note).to eq('A reply!') + expect(draft.discussion_id).to eq(discussion.reply_id) + expect(draft.resolve_discussion).to be_falsey + end + + it 'creates a draft that resolves the thread' do + discussion = create(:discussion_note_on_merge_request, noteable: merge_request, project: project).discussion + + draft = create_draft(note: 'A reply!', in_reply_to_discussion_id: discussion.reply_id, resolve_discussion: true) + + expect(draft.note).to eq('A reply!') + expect(draft.discussion_id).to eq(discussion.reply_id) + expect(draft.resolve_discussion).to be true + end + end + + it 'creates a draft note with a position in a diff' do + diff_refs = project.commit(RepoHelpers.sample_commit.id).try(:diff_refs) + + position = Gitlab::Diff::Position.new( + old_path: "files/ruby/popen.rb", + new_path: "files/ruby/popen.rb", + old_line: nil, + new_line: 14, + diff_refs: diff_refs + ) + + draft = create_draft(note: 'Comment on diff', position: position.to_json) + + expect(draft.note).to eq('Comment on diff') + expect(draft.original_position.to_json).to eq(position.to_json) + end + + context 'diff highlight cache clearing' do + context 'when diff file is unfolded and it is not a reply' do + it 'clears diff highlighting cache' do + expect_next_instance_of(DraftNote) do |draft| + allow(draft).to receive_message_chain(:diff_file, :unfolded?) { true } + end + + expect(merge_request).to receive_message_chain(:diffs, :clear_cache) + + create_draft(note: 'This is a test') + end + end + + context 'when diff file is not unfolded and it is not a reply' do + it 'clears diff highlighting cache' do + expect_next_instance_of(DraftNote) do |draft| + allow(draft).to receive_message_chain(:diff_file, :unfolded?) { false } + end + + expect(merge_request).not_to receive(:diffs) + + create_draft(note: 'This is a test') + end + end + end +end diff --git a/spec/services/draft_notes/destroy_service_spec.rb b/spec/services/draft_notes/destroy_service_spec.rb new file mode 100644 index 00000000000..d0bf88dcdbe --- /dev/null +++ b/spec/services/draft_notes/destroy_service_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe DraftNotes::DestroyService do + let(:merge_request) { create(:merge_request) } + let(:project) { merge_request.target_project } + let(:user) { merge_request.author } + + def destroy(draft_note = nil) + DraftNotes::DestroyService.new(merge_request, user).execute(draft_note) + end + + it 'destroys a single draft note' do + drafts = create_list(:draft_note, 2, merge_request: merge_request, author: user) + + expect { destroy(drafts.first) } + .to change { DraftNote.count }.by(-1) + + expect(DraftNote.count).to eq(1) + end + + it 'destroys all draft notes for a user in a merge request' do + create_list(:draft_note, 2, merge_request: merge_request, author: user) + + expect { destroy }.to change { DraftNote.count }.by(-2) + expect(DraftNote.count).to eq(0) + end + + context 'diff highlight cache clearing' do + context 'when destroying all draft notes of a user' do + it 'clears highlighting cache if unfold required for any' do + drafts = create_list(:draft_note, 2, merge_request: merge_request, author: user) + + allow_any_instance_of(DraftNote).to receive_message_chain(:diff_file, :unfolded?) { true } + expect(merge_request).to receive_message_chain(:diffs, :clear_cache) + + destroy(drafts.first) + end + end + + context 'when destroying one draft note' do + it 'clears highlighting cache if unfold required' do + create_list(:draft_note, 2, merge_request: merge_request, author: user) + + allow_any_instance_of(DraftNote).to receive_message_chain(:diff_file, :unfolded?) { true } + expect(merge_request).to receive_message_chain(:diffs, :clear_cache) + + destroy + end + end + end +end diff --git a/spec/services/draft_notes/publish_service_spec.rb b/spec/services/draft_notes/publish_service_spec.rb new file mode 100644 index 00000000000..4ebae2f9aa2 --- /dev/null +++ b/spec/services/draft_notes/publish_service_spec.rb @@ -0,0 +1,261 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe DraftNotes::PublishService do + include RepoHelpers + + let(:merge_request) { create(:merge_request) } + let(:project) { merge_request.target_project } + let(:user) { merge_request.author } + let(:commit) { project.commit(sample_commit.id) } + + let(:position) do + Gitlab::Diff::Position.new( + old_path: "files/ruby/popen.rb", + new_path: "files/ruby/popen.rb", + old_line: nil, + new_line: 14, + diff_refs: commit.diff_refs + ) + end + + def publish(draft: nil) + DraftNotes::PublishService.new(merge_request, user).execute(draft) + end + + context 'single draft note' do + let(:commit_id) { nil } + let!(:drafts) { create_list(:draft_note, 2, merge_request: merge_request, author: user, commit_id: commit_id, position: position) } + + it 'publishes' do + expect { publish(draft: drafts.first) }.to change { DraftNote.count }.by(-1).and change { Note.count }.by(1) + expect(DraftNote.count).to eq(1) + end + + it 'does not skip notification', :sidekiq_might_not_need_inline do + expect(Notes::CreateService).to receive(:new).with(project, user, drafts.first.publish_params).and_call_original + expect_next_instance_of(NotificationService) do |notification_service| + expect(notification_service).to receive(:new_note) + end + + result = publish(draft: drafts.first) + + expect(result[:status]).to eq(:success) + end + + context 'commit_id is set' do + let(:commit_id) { commit.id } + + it 'creates note from draft with commit_id' do + result = publish(draft: drafts.first) + + expect(result[:status]).to eq(:success) + expect(merge_request.notes.first.commit_id).to eq(commit_id) + end + end + end + + context 'multiple draft notes' do + let(:commit_id) { nil } + + before do + create(:draft_note, merge_request: merge_request, author: user, note: 'first note', commit_id: commit_id, position: position) + create(:draft_note, merge_request: merge_request, author: user, note: 'second note', commit_id: commit_id, position: position) + end + + context 'when review fails to create' do + before do + expect_next_instance_of(Review) do |review| + allow(review).to receive(:save!).and_raise(ActiveRecord::RecordInvalid.new(review)) + end + end + + it 'does not publish any draft note' do + expect { publish }.not_to change { DraftNote.count } + end + + it 'returns an error' do + result = publish + + expect(result[:status]).to eq(:error) + expect(result[:message]).to match(/Unable to save Review/) + end + end + + it 'returns success' do + result = publish + + expect(result[:status]).to eq(:success) + end + + it 'publishes all draft notes for a user in a merge request' do + expect { publish }.to change { DraftNote.count }.by(-2).and change { Note.count }.by(2).and change { Review.count }.by(1) + expect(DraftNote.count).to eq(0) + + notes = merge_request.notes.order(id: :asc) + expect(notes.first.note).to eq('first note') + expect(notes.last.note).to eq('second note') + end + + it 'sends batch notification' do + expect_next_instance_of(NotificationService) do |notification_service| + expect(notification_service).to receive_message_chain(:async, :new_review).with(kind_of(Review)) + end + + publish + end + + context 'commit_id is set' do + let(:commit_id) { commit.id } + + it 'creates note from draft with commit_id' do + result = publish + + expect(result[:status]).to eq(:success) + + merge_request.notes.each do |note| + expect(note.commit_id).to eq(commit_id) + end + end + end + end + + context 'draft notes with suggestions' do + let(:project) { create(:project, :repository) } + let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } + + let(:suggestion_note) do + <<-MARKDOWN.strip_heredoc + ```suggestion + foo + ``` + MARKDOWN + end + + let!(:draft) { create(:draft_note_on_text_diff, note: suggestion_note, merge_request: merge_request, author: user) } + + it 'creates a suggestion with correct content' do + expect { publish(draft: draft) }.to change { Suggestion.count }.by(1) + .and change { DiffNote.count }.from(0).to(1) + + suggestion = Suggestion.last + + expect(suggestion.from_line).to eq(14) + expect(suggestion.to_line).to eq(14) + expect(suggestion.from_content).to eq(" vars = {\n") + expect(suggestion.to_content).to eq(" foo\n") + end + + context 'when the diff is changed' do + let(:file_path) { 'files/ruby/popen.rb' } + let(:branch_name) { project.default_branch } + let(:commit) { project.repository.commit } + + def update_file(file_path, new_content) + params = { + file_path: file_path, + commit_message: "Update File", + file_content: new_content, + start_project: project, + start_branch: project.default_branch, + branch_name: branch_name + } + + Files::UpdateService.new(project, user, params).execute + end + + before do + project.add_developer(user) + end + + it 'creates a suggestion based on the latest diff content and positions' do + diff_file = merge_request.diffs(paths: [file_path]).diff_files.first + raw_data = diff_file.new_blob.data + + # Add a line break to the beginning of the file + result = update_file(file_path, raw_data.prepend("\n")) + oldrev = merge_request.diff_head_sha + newrev = result[:result] + + expect(newrev).to be_present + + # Generates new MR revision at DB level + refresh = MergeRequests::RefreshService.new(project, user) + refresh.execute(oldrev, newrev, merge_request.source_branch_ref) + + expect { publish(draft: draft) }.to change { Suggestion.count }.by(1) + .and change { DiffNote.count }.from(0).to(1) + + suggestion = Suggestion.last + + expect(suggestion.from_line).to eq(15) + expect(suggestion.to_line).to eq(15) + expect(suggestion.from_content).to eq(" vars = {\n") + expect(suggestion.to_content).to eq(" foo\n") + end + end + end + + it 'only publishes the draft notes belonging to the current user' do + other_user = create(:user) + project.add_maintainer(other_user) + + create_list(:draft_note, 2, merge_request: merge_request, author: user) + create_list(:draft_note, 2, merge_request: merge_request, author: other_user) + + expect { publish }.to change { DraftNote.count }.by(-2).and change { Note.count }.by(2) + expect(DraftNote.count).to eq(2) + end + + context 'with quick actions' do + it 'performs quick actions' do + other_user = create(:user) + project.add_developer(other_user) + + create(:draft_note, merge_request: merge_request, + author: user, + note: "thanks\n/assign #{other_user.to_reference}") + + expect { publish }.to change { DraftNote.count }.by(-1).and change { Note.count }.by(2) + expect(merge_request.reload.assignees).to match_array([other_user]) + expect(merge_request.notes.last).to be_system + end + + it 'does not create a note if it only contains quick actions' do + create(:draft_note, merge_request: merge_request, author: user, note: "/assign #{user.to_reference}") + + expect { publish }.to change { DraftNote.count }.by(-1).and change { Note.count }.by(1) + expect(merge_request.reload.assignees).to eq([user]) + expect(merge_request.notes.last).to be_system + end + end + + context 'with drafts that resolve threads' do + let!(:note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project) } + let!(:draft_note) { create(:draft_note, merge_request: merge_request, author: user, resolve_discussion: true, discussion_id: note.discussion.reply_id) } + + it 'resolves the thread' do + publish(draft: draft_note) + + expect(note.discussion.resolved?).to be true + end + + it 'sends notifications if all threads are resolved' do + expect_next_instance_of(MergeRequests::ResolvedDiscussionNotificationService) do |instance| + expect(instance).to receive(:execute).with(merge_request) + end + + publish + end + end + + context 'user cannot create notes' do + before do + allow(Ability).to receive(:allowed?).with(user, :create_note, merge_request).and_return(false) + end + + it 'returns an error' do + expect(publish[:status]).to eq(:error) + end + end +end diff --git a/spec/services/notification_recipients/build_service_spec.rb b/spec/services/notification_recipients/build_service_spec.rb index 2e848c2f04d..e203093623d 100644 --- a/spec/services/notification_recipients/build_service_spec.rb +++ b/spec/services/notification_recipients/build_service_spec.rb @@ -58,4 +58,56 @@ describe NotificationRecipients::BuildService do end end end + + describe '#build_new_review_recipients' do + let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } + let(:review) { create(:review, merge_request: merge_request, project: project, author: merge_request.author) } + let(:notes) { create_list(:note_on_merge_request, 3, review: review, noteable: review.merge_request, project: review.project) } + + shared_examples 'no N+1 queries' do + it 'avoids N+1 queries', :request_store do + create_user + + service.build_new_review_recipients(review) + + control_count = ActiveRecord::QueryRecorder.new do + service.build_new_review_recipients(review) + end + + create_user + + expect { service.build_new_review_recipients(review) }.not_to exceed_query_limit(control_count) + end + end + + context 'when there are multiple watchers' do + def create_user + watcher = create(:user) + create(:notification_setting, source: project, user: watcher, level: :watch) + + other_projects.each do |other_project| + create(:notification_setting, source: other_project, user: watcher, level: :watch) + end + end + + include_examples 'no N+1 queries' + end + + context 'when there are multiple subscribers' do + def create_user + subscriber = create(:user) + merge_request.subscriptions.create(user: subscriber, project: project, subscribed: true) + end + + include_examples 'no N+1 queries' + + context 'when the project is private' do + before do + project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + end + + include_examples 'no N+1 queries' + end + end + end end diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 2a7166e3895..d3376ef0a04 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -2863,6 +2863,57 @@ describe NotificationService, :mailer do end end + describe '#new_review' do + let(:project) { create(:project, :repository) } + let(:user) { create(:user) } + let(:user2) { create(:user) } + let(:reviewer) { create(:user) } + let(:merge_request) { create(:merge_request, source_project: project, assignees: [user, user2], author: create(:user)) } + let(:review) { create(:review, merge_request: merge_request, project: project, author: reviewer) } + let(:note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, author: reviewer, review: review) } + + before do + build_team(review.project) + add_users(review.project) + add_user_subscriptions(merge_request) + project.add_maintainer(merge_request.author) + project.add_maintainer(reviewer) + merge_request.assignees.each { |assignee| project.add_maintainer(assignee) } + + create(:diff_note_on_merge_request, + project: project, + noteable: merge_request, + author: reviewer, + review: review, + note: "cc @mention") + end + + it 'sends emails' do + expect(Notify).not_to receive(:new_review_email).with(review.author.id, review.id) + expect(Notify).not_to receive(:new_review_email).with(@unsubscriber.id, review.id) + merge_request.assignee_ids.each do |assignee_id| + expect(Notify).to receive(:new_review_email).with(assignee_id, review.id).and_call_original + end + expect(Notify).to receive(:new_review_email).with(merge_request.author.id, review.id).and_call_original + expect(Notify).to receive(:new_review_email).with(@u_watcher.id, review.id).and_call_original + expect(Notify).to receive(:new_review_email).with(@u_mentioned.id, review.id).and_call_original + expect(Notify).to receive(:new_review_email).with(@subscriber.id, review.id).and_call_original + expect(Notify).to receive(:new_review_email).with(@watcher_and_subscriber.id, review.id).and_call_original + expect(Notify).to receive(:new_review_email).with(@subscribed_participant.id, review.id).and_call_original + + subject.new_review(review) + end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { review } + let(:notification_trigger) { subject.new_review(review) } + + around do |example| + perform_enqueued_jobs { example.run } + end + end + end + def build_team(project) @u_watcher = create_global_setting_for(create(:user), :watch) @u_participating = create_global_setting_for(create(:user), :participating) diff --git a/spec/services/prometheus/proxy_variable_substitution_service_spec.rb b/spec/services/prometheus/proxy_variable_substitution_service_spec.rb index 82ea356d599..5982dcbc404 100644 --- a/spec/services/prometheus/proxy_variable_substitution_service_spec.rb +++ b/spec/services/prometheus/proxy_variable_substitution_service_spec.rb @@ -64,7 +64,7 @@ describe Prometheus::ProxyVariableSubstitutionService do let(:params_keys) do { query: 'up{pod_name="{{pod_name}}"}', - variables: ['pod_name', pod_name] + variables: { 'pod_name' => pod_name } } end @@ -76,7 +76,7 @@ describe Prometheus::ProxyVariableSubstitutionService do let(:params_keys) do { query: 'up{pod_name="{{pod_name}}",env="{{ci_environment_slug}}"}', - variables: ['pod_name', pod_name, 'ci_environment_slug', 'custom_value'] + variables: { 'pod_name' => pod_name, 'ci_environment_slug' => 'custom_value' } } end @@ -95,8 +95,7 @@ describe Prometheus::ProxyVariableSubstitutionService do } end - it_behaves_like 'error', 'Optional parameter "variables" must be an ' \ - 'array of keys and values. Ex: [key1, value1, key2, value2]' + it_behaves_like 'error', 'Optional parameter "variables" must be a Hash. Ex: variables[key1]=value1' end context 'with nil variables' do diff --git a/spec/services/users/migrate_to_ghost_user_service_spec.rb b/spec/services/users/migrate_to_ghost_user_service_spec.rb index a7d7c16a66f..c2a793b2368 100644 --- a/spec/services/users/migrate_to_ghost_user_service_spec.rb +++ b/spec/services/users/migrate_to_ghost_user_service_spec.rb @@ -84,6 +84,15 @@ describe Users::MigrateToGhostUserService do end end + context 'reviews' do + let!(:user) { create(:user) } + let(:service) { described_class.new(user) } + + include_examples "migrating a deleted user's associated records to the ghost user", Review, [:author] do + let(:created_record) { create(:review, author: user) } + end + end + context "when record migration fails with a rollback exception" do before do expect_any_instance_of(ActiveRecord::Associations::CollectionProxy) diff --git a/spec/support/shared_examples/controllers/issuables_list_metadata_shared_examples.rb b/spec/support/shared_examples/controllers/issuables_list_metadata_shared_examples.rb index 2dbaea57c44..62a1a07b6c1 100644 --- a/spec/support/shared_examples/controllers/issuables_list_metadata_shared_examples.rb +++ b/spec/support/shared_examples/controllers/issuables_list_metadata_shared_examples.rb @@ -34,7 +34,7 @@ RSpec.shared_examples 'issuables list meta-data' do |issuable_type, action = nil aggregate_failures do expect(meta_data.keys).to match_array(issuables.map(&:id)) - expect(meta_data.values).to all(be_kind_of(Issuable::IssuableMeta)) + expect(meta_data.values).to all(be_kind_of(Gitlab::IssuableMetadata::IssuableMeta)) end end diff --git a/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb b/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb index 4bcea36fd42..d21823661f8 100644 --- a/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb @@ -26,7 +26,7 @@ RSpec.shared_examples 'includes Limitable concern' do subject.dup.save end - it 'cannot create new models exceding the plan limits' do + it 'cannot create new models exceeding the plan limits' do expect { subject.save }.not_to change { described_class.count } expect(subject.errors[:base]).to contain_exactly("Maximum number of #{subject.class.limit_name.humanize(capitalize: false)} (1) exceeded") end diff --git a/yarn.lock b/yarn.lock index 0c2d52e6df2..b3f4be1897e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -787,7 +787,7 @@ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.130.0.tgz#0c2f3cdc0a4b0f54c47b2861c8fa31b2a58c570a" integrity sha512-azJ1E9PBk6fGOaP6816BSr8oYrQu3m3BbYZwWOCUp8AfbZuf0ZOZVYmlR9i/eAOhoqqqmwF8hYCK2VjAklbpPA== -"@gitlab/ui@16.0": +"@gitlab/ui@16.0.0": version "16.0.0" resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-16.0.0.tgz#0e2d19b85c47f45a052caf6cd0367613cbab8e8e" integrity sha512-xSWXtFWWQzGtL35dGexc5LGqAJXYjLMEFQyPLzCBX3yY9tkI9s9rVMX053tnKYb9kgEmL+R/xGiW7D9nb58VmQ== |