diff options
331 files changed, 5218 insertions, 1218 deletions
diff --git a/.gitlab/issue_templates/Feature Flag Roll Out.md b/.gitlab/issue_templates/Feature Flag Roll Out.md index b7db5a33faf..0cac769bd55 100644 --- a/.gitlab/issue_templates/Feature Flag Roll Out.md +++ b/.gitlab/issue_templates/Feature Flag Roll Out.md @@ -39,5 +39,6 @@ If applicable, any groups/projects that are happy to have this feature turned on - [ ] Cross post chatops slack command to `#support_gitlab-com` and in your team channel - [ ] Announce on the issue that the flag has been enabled - [ ] Remove feature flag and add changelog entry +- [ ] After the flag removal is deployed, [clean up the feature flag](https://docs.gitlab.com/ee/development/feature_flags/controls.html#cleaning-up) by running chatops command in `#production` channel /label ~"feature flag" diff --git a/.rubocop.yml b/.rubocop.yml index 79e06439ac2..b75c63e1f58 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -8,7 +8,7 @@ require: - rubocop-rspec AllCops: - TargetRubyVersion: 2.5 + TargetRubyVersion: 2.6 TargetRailsVersion: 5.0 Exclude: - 'vendor/**/*' @@ -51,6 +51,7 @@ gem 'jwt', '~> 2.1.0' # Spam and anti-bot protection gem 'recaptcha', '~> 4.11', require: 'recaptcha/rails' gem 'akismet', '~> 2.0' +gem 'invisible_captcha', '~> 0.12.1' # Two-factor authentication gem 'devise-two-factor', '~> 3.0.0' @@ -297,6 +298,9 @@ gem 'batch-loader', '~> 1.4.0' # Perf bar gem 'peek', '~> 1.0.1' +# Snowplow events tracking +gem 'snowplow-tracker', '~> 0.6.1' + # Memory benchmarks gem 'derailed_benchmarks', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 6aa96d54abb..fcc0fb64897 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -152,6 +152,7 @@ GEM concurrent-ruby-ext (1.1.5) concurrent-ruby (= 1.1.5) connection_pool (2.2.2) + contracts (0.11.0) crack (0.4.3) safe_yaml (~> 1.0.0) crass (1.0.4) @@ -437,6 +438,8 @@ GEM influxdb (0.2.3) cause json + invisible_captcha (0.12.1) + rails (>= 3.2.0) ipaddress (0.8.3) jaeger-client (0.10.0) opentracing (~> 0.3) @@ -843,7 +846,7 @@ GEM rubyntlm (0.6.2) rubypants (0.2.0) rubyzip (1.2.2) - rugged (0.28.2) + rugged (0.28.3.1) safe_yaml (1.0.4) sanitize (4.6.6) crass (~> 1.0.2) @@ -901,6 +904,8 @@ GEM simplecov-html (~> 0.10.0) simplecov-html (0.10.2) slack-notifier (1.5.1) + snowplow-tracker (0.6.1) + contracts (~> 0.7, <= 0.11) spring (2.0.2) activesupport (>= 4.2) spring-commands-rspec (1.0.4) @@ -1126,6 +1131,7 @@ DEPENDENCIES httparty (~> 0.16.4) icalendar influxdb (~> 0.2) + invisible_captcha (~> 0.12.1) jira-ruby (~> 1.4) js_regex (~> 3.1) json-schema (~> 2.8.0) @@ -1229,6 +1235,7 @@ DEPENDENCIES simple_po_parser (~> 1.1.2) simplecov (~> 0.16.1) slack-notifier (~> 1.5.1) + snowplow-tracker (~> 0.6.1) spring (~> 2.0.0) spring-commands-rspec (~> 1.0.4) sprockets (~> 3.7.0) diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js index eade1283513..7e3515b1f4b 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js @@ -4,7 +4,7 @@ import Mousetrap from 'mousetrap'; import axios from '../../lib/utils/axios_utils'; import { refreshCurrentPage, visitUrl } from '../../lib/utils/url_utility'; import findAndFollowLink from '../../lib/utils/navigation_utility'; -import { parseBoolean } from '~/lib/utils/common_utils'; +import { parseBoolean, getCspNonceValue } from '~/lib/utils/common_utils'; const defaultStopCallback = Mousetrap.stopCallback; Mousetrap.stopCallback = (e, element, combo) => { @@ -94,7 +94,7 @@ export default class Shortcuts { responseType: 'text', }) .then(({ data }) => { - $.globalEval(data); + $.globalEval(data, { nonce: getCspNonceValue() }); if (location && location.length > 0) { const results = []; diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue index 80a6ab9598a..7254c50a568 100644 --- a/app/assets/javascripts/ide/components/file_row_extra.vue +++ b/app/assets/javascripts/ide/components/file_row_extra.vue @@ -87,7 +87,6 @@ export default { :file="file" :show-tooltip="true" :show-staged-icon="true" - :force-modified-icon="true" /> <new-dropdown :type="file.type" diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 9e97f345717..ba33d72b1f3 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -107,6 +107,7 @@ function deferredInitialisation() { .then(() => { $('select.select2').select2({ width: 'resolve', + minimumResultsForSearch: 10, dropdownAutoWidth: true, }); diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue index 5b950f8c966..838447e6c75 100644 --- a/app/assets/javascripts/monitoring/components/charts/area.vue +++ b/app/assets/javascripts/monitoring/components/charts/area.vue @@ -1,7 +1,6 @@ <script> import { __ } from '~/locale'; -import { mapState } from 'vuex'; -import { GlLink, GlButton } from '@gitlab/ui'; +import { GlLink } from '@gitlab/ui'; import { GlAreaChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import dateFormat from 'dateformat'; import { debounceByAnimationFrame, roundOffFloat } from '~/lib/utils/common_utils'; @@ -16,7 +15,6 @@ let debouncedResize; export default { components: { GlAreaChart, - GlButton, GlChartSeriesLabel, GlLink, Icon, @@ -69,7 +67,6 @@ export default { }; }, computed: { - ...mapState('monitoringDashboard', ['exportMetricsToCsvEnabled']), chartData() { // Transforms & supplements query data to render appropriate labels & styles // Input: [{ queryAttributes1 }, { queryAttributes2 }] @@ -179,18 +176,6 @@ export default { yAxisLabel() { return `${this.graphData.y_label}`; }, - csvText() { - const chartData = this.chartData[0].data; - const header = `timestamp,${this.graphData.y_label}\r\n`; // eslint-disable-line @gitlab/i18n/no-non-i18n-strings - return chartData.reduce((csv, data) => { - const row = data.join(','); - return `${csv}${row}\r\n`; - }, header); - }, - downloadLink() { - const data = new Blob([this.csvText], { type: 'text/plain' }); - return window.URL.createObjectURL(data); - }, }, watch: { containerWidth: 'onResize', @@ -259,16 +244,6 @@ export default { <div :class="{ 'prometheus-graph-embed w-100 p-3': showBorder }"> <div class="prometheus-graph-header"> <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5> - <gl-button - v-if="exportMetricsToCsvEnabled" - :href="downloadLink" - :title="__('Download CSV')" - :aria-label="__('Download CSV')" - style="margin-left: 200px;" - download="chart_metrics.csv" - > - {{ __('Download CSV') }} - </gl-button> <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div> </div> <gl-area-chart diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 782e4310f3e..dfeeba238ca 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -10,9 +10,9 @@ import { } from '@gitlab/ui'; import _ from 'underscore'; import { mapActions, mapState } from 'vuex'; -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; -import { getParameterValues } from '~/lib/utils/url_utility'; +import { getParameterValues, mergeUrlParams } from '~/lib/utils/url_utility'; import invalidUrl from '~/lib/utils/invalid_url'; import MonitorAreaChart from './charts/area.vue'; import MonitorSingleStatChart from './charts/single_stat.vue'; @@ -168,8 +168,11 @@ export default { 'multipleDashboardsEnabled', 'additionalPanelTypesEnabled', ]), + firstDashboard() { + return this.allDashboards[0] || {}; + }, selectedDashboardText() { - return this.currentDashboard || (this.allDashboards[0] && this.allDashboards[0].display_name); + return this.currentDashboard || this.firstDashboard.display_name; }, addingMetricsAvailable() { return IS_EE && this.canAddMetrics && !this.showEmptyState; @@ -235,6 +238,19 @@ export default { chart.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)), ); }, + csvText(graphData) { + const chartData = graphData.queries[0].result[0].values; + const yLabel = graphData.y_label; + const header = `timestamp,${yLabel}\r\n`; // eslint-disable-line @gitlab/i18n/no-non-i18n-strings + return chartData.reduce((csv, data) => { + const row = data.join(','); + return `${csv}${row}\r\n`; + }, header); + }, + downloadCsv(graphData) { + const data = new Blob([this.csvText(graphData)], { type: 'text/plain' }); + return window.URL.createObjectURL(data); + }, // TODO: BEGIN, Duplicated code with panel_type until feature flag is removed // Issue number: https://gitlab.com/gitlab-org/gitlab-ce/issues/63845 getGraphAlerts(queries) { @@ -245,6 +261,14 @@ export default { getGraphAlertValues(queries) { return Object.values(this.getGraphAlerts(queries)); }, + showToast() { + this.$toast.show(__('Link copied to clipboard')); + }, + generateLink(group, title, yLabel) { + const dashboard = this.currentDashboard || this.firstDashboard.path; + const params = { dashboard, group, title, y_label: yLabel }; + return mergeUrlParams(params, window.location.href); + }, // TODO: END hideAddMetricModal() { this.$refs.addMetricModal.hide(); @@ -422,6 +446,7 @@ export default { <panel-type v-for="(graphData, graphIndex) in groupData.metrics" :key="`panel-type-${graphIndex}`" + :clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)" :graph-data="graphData" :dashboard-width="elWidth" :index="`${index}-${graphIndex}`" @@ -448,7 +473,6 @@ export default { @setAlerts="setAlerts" /> <gl-dropdown - v-if="alertWidgetAvailable" v-gl-tooltip class="mx-2" toggle-class="btn btn-transparent border-0" @@ -459,6 +483,18 @@ export default { <template slot="button-content"> <icon name="ellipsis_v" class="text-secondary" /> </template> + <gl-dropdown-item :href="downloadCsv(graphData)" download="chart_metrics.csv"> + {{ __('Download CSV') }} + </gl-dropdown-item> + <gl-dropdown-item + class="js-chart-link" + :data-clipboard-text=" + generateLink(groupData.group, graphData.title, graphData.y_label) + " + @click="showToast" + > + {{ __('Generate link to chart') }} + </gl-dropdown-item> <gl-dropdown-item v-if="alertWidgetAvailable" v-gl-modal="`alert-modal-${index}-${graphIndex}`" diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue index 295c0851f12..96f62bc85ee 100644 --- a/app/assets/javascripts/monitoring/components/panel_type.vue +++ b/app/assets/javascripts/monitoring/components/panel_type.vue @@ -1,7 +1,15 @@ <script> import { mapState } from 'vuex'; import _ from 'underscore'; -import { GlDropdown, GlDropdownItem, GlModal, GlModalDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { + GlDropdown, + GlDropdownItem, + GlModal, + GlModalDirective, + GlTooltipDirective, +} from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; import MonitorAreaChart from './charts/area.vue'; import MonitorSingleStatChart from './charts/single_stat.vue'; import MonitorEmptyChart from './charts/empty_chart.vue'; @@ -11,14 +19,20 @@ export default { MonitorAreaChart, MonitorSingleStatChart, MonitorEmptyChart, + Icon, GlDropdown, GlDropdownItem, GlModal, }, directives: { GlModal: GlModalDirective, + GlTooltip: GlTooltipDirective, }, props: { + clipboardText: { + type: String, + required: true, + }, graphData: { type: Object, required: true, @@ -41,6 +55,19 @@ export default { graphDataHasMetrics() { return this.graphData.queries[0].result.length > 0; }, + csvText() { + const chartData = this.graphData.queries[0].result[0].values; + const yLabel = this.graphData.y_label; + const header = `timestamp,${yLabel}\r\n`; // eslint-disable-line @gitlab/i18n/no-non-i18n-strings + return chartData.reduce((csv, data) => { + const row = data.join(','); + return `${csv}${row}\r\n`; + }, header); + }, + downloadCsv() { + const data = new Blob([this.csvText], { type: 'text/plain' }); + return window.URL.createObjectURL(data); + }, }, methods: { getGraphAlerts(queries) { @@ -54,6 +81,9 @@ export default { isPanelType(type) { return this.graphData.type && this.graphData.type === type; }, + showToast() { + this.$toast.show(__('Link copied to clipboard')); + }, }, }; </script> @@ -81,7 +111,6 @@ export default { @setAlerts="setAlerts" /> <gl-dropdown - v-if="alertWidgetAvailable" v-gl-tooltip class="mx-2" toggle-class="btn btn-transparent border-0" @@ -92,6 +121,16 @@ export default { <template slot="button-content"> <icon name="ellipsis_v" class="text-secondary" /> </template> + <gl-dropdown-item :href="downloadCsv" download="chart_metrics.csv"> + {{ __('Download CSV') }} + </gl-dropdown-item> + <gl-dropdown-item + class="js-chart-link" + :data-clipboard-text="clipboardText" + @click="showToast" + > + {{ __('Generate link to chart') }} + </gl-dropdown-item> <gl-dropdown-item v-if="alertWidgetAvailable" v-gl-modal="`alert-modal-${index}`"> {{ __('Alerts') }} </gl-dropdown-item> diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index 366034becd0..51cef20455c 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -1,9 +1,12 @@ import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; import { parseBoolean } from '~/lib/utils/common_utils'; import { getParameterValues } from '~/lib/utils/url_utility'; import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue'; import store from './stores'; +Vue.use(GlToast); + export default (props = {}) => { const el = document.getElementById('prometheus-graphs'); @@ -13,7 +16,6 @@ export default (props = {}) => { prometheusEndpointEnabled: gon.features.environmentMetricsUsePrometheusEndpoint, multipleDashboardsEnabled: gon.features.environmentMetricsShowMultipleDashboards, additionalPanelTypesEnabled: gon.features.environmentMetricsAdditionalPanelTypes, - exportMetricsToCsvEnabled: gon.features.exportMetricsToCsvEnabled, }); } diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index a9c491c7c6c..0cbad179f17 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -37,17 +37,11 @@ export const setEndpoints = ({ commit }, endpoints) => { export const setFeatureFlags = ( { commit }, - { - prometheusEndpointEnabled, - multipleDashboardsEnabled, - additionalPanelTypesEnabled, - exportMetricsToCsvEnabled, - }, + { prometheusEndpointEnabled, multipleDashboardsEnabled, additionalPanelTypesEnabled }, ) => { commit(types.SET_DASHBOARD_ENABLED, prometheusEndpointEnabled); commit(types.SET_MULTIPLE_DASHBOARDS_ENABLED, multipleDashboardsEnabled); commit(types.SET_ADDITIONAL_PANEL_TYPES_ENABLED, additionalPanelTypesEnabled); - commit(types.SET_EXPORT_METRICS_TO_CSV_ENABLED, exportMetricsToCsvEnabled); }; export const setShowErrorBanner = ({ commit }, enabled) => { diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index 9ec8214b167..4b1aadbcf05 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -17,4 +17,3 @@ export const SET_ENDPOINTS = 'SET_ENDPOINTS'; export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE'; export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE'; export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER'; -export const SET_EXPORT_METRICS_TO_CSV_ENABLED = 'SET_EXPORT_METRICS_TO_CSV_ENABLED'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index a2dceb21fc0..b19520d6638 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -99,7 +99,4 @@ export default { [types.SET_SHOW_ERROR_BANNER](state, enabled) { state.showErrorBanner = enabled; }, - [types.SET_EXPORT_METRICS_TO_CSV_ENABLED](state, enabled) { - state.exportMetricsToCsvEnabled = enabled; - }, }; diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index a14a25e3a20..440bdc951e0 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -10,7 +10,6 @@ export default () => ({ useDashboardEndpoint: false, multipleDashboardsEnabled: false, additionalPanelTypesEnabled: false, - exportMetricsToCsvEnabled: false, emptyState: 'gettingStarted', showEmptyState: true, showErrorBanner: true, diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js index 8caac68e0d4..622db360d1f 100644 --- a/app/assets/javascripts/mr_notes/init_notes.js +++ b/app/assets/javascripts/mr_notes/init_notes.js @@ -59,6 +59,10 @@ export default () => { render(createElement) { const isDiffView = this.activeTab === 'diffs'; + // NOTE: Even though `discussionKeyboardNavigator` is added to the `notes-app`, + // it adds a global key listener so it works on the diffs tab as well. + // If we create a single Vue app for all of the MR tabs, we should move this + // up the tree, to the root. return createElement(discussionKeyboardNavigator, { props: { isDiffView } }, [ createElement('notes-app', { props: { diff --git a/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue b/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue index 5fc2b6ba04c..7fbfe8eebb2 100644 --- a/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue +++ b/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue @@ -25,6 +25,10 @@ export default { Mousetrap.bind('n', () => this.jumpToNextDiscussion()); Mousetrap.bind('p', () => this.jumpToPreviousDiscussion()); }, + beforeDestroy() { + Mousetrap.unbind('n'); + Mousetrap.unbind('p'); + }, methods: { ...mapActions(['expandDiscussion']), jumpToNextDiscussion() { diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue index e157036871b..bfb2305c48c 100644 --- a/app/assets/javascripts/registry/components/collapsible_container.vue +++ b/app/assets/javascripts/registry/components/collapsible_container.vue @@ -84,7 +84,7 @@ export default { v-gl-modal="modalId" :title="s__('ContainerRegistry|Remove repository')" :aria-label="s__('ContainerRegistry|Remove repository')" - class="js-remove-repo" + class="js-remove-repo btn-inverted" variant="danger" > <icon name="remove" /> diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue index a498a553908..e9067bc2b56 100644 --- a/app/assets/javascripts/registry/components/table_registry.vue +++ b/app/assets/javascripts/registry/components/table_registry.vue @@ -1,7 +1,13 @@ <script> import { mapActions } from 'vuex'; -import { GlButton, GlTooltipDirective, GlModal, GlModalDirective } from '@gitlab/ui'; -import { n__ } from '../../locale'; +import { + GlButton, + GlFormCheckbox, + GlTooltipDirective, + GlModal, + GlModalDirective, +} from '@gitlab/ui'; +import { n__, s__, sprintf } from '../../locale'; import createFlash from '../../flash'; import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; import TablePagination from '../../vue_shared/components/pagination/table_pagination.vue'; @@ -14,6 +20,7 @@ export default { components: { ClipboardButton, TablePagination, + GlFormCheckbox, GlButton, Icon, GlModal, @@ -31,33 +38,98 @@ export default { }, data() { return { - itemToBeDeleted: null, + itemsToBeDeleted: [], modalId: `confirm-image-deletion-modal-${this.repo.id}`, + selectAllChecked: false, + modalDescription: '', }; }, computed: { + bulkDeletePath() { + return this.repo.tagsPath ? this.repo.tagsPath.replace('?format=json', '/bulk_destroy') : ''; + }, shouldRenderPagination() { return this.repo.pagination.total > this.repo.pagination.perPage; }, + modalTitle() { + return n__( + 'ContainerRegistry|Remove image', + 'ContainerRegistry|Remove images', + this.itemsToBeDeleted.length === 0 ? 1 : this.itemsToBeDeleted.length, + ); + }, + }, + mounted() { + this.$refs.deleteModal.$refs.modal.$on('hide', this.removeModalEvents); }, methods: { - ...mapActions(['fetchList', 'deleteItem']), + ...mapActions(['fetchList', 'deleteItem', 'multiDeleteItems']), + setModalDescription(itemIndex = -1) { + if (itemIndex === -1) { + this.modalDescription = sprintf( + s__(`ContainerRegistry|You are about to delete <b>%{count}</b> images. This will + delete the images and all tags pointing to them.`), + { count: this.itemsToBeDeleted.length }, + ); + } else { + const { tag } = this.repo.list[itemIndex]; + + this.modalDescription = sprintf( + s__(`ContainerRegistry|You are about to delete the image <b>%{title}</b>. This will + delete the image and all tags pointing to this image.`), + { title: `${this.repo.name}:${tag}` }, + ); + } + }, layers(item) { return item.layers ? n__('%d layer', '%d layers', item.layers) : ''; }, formatSize(size) { return numberToHumanSize(size); }, - setItemToBeDeleted(item) { - this.itemToBeDeleted = item; + removeModalEvents() { + this.$refs.deleteModal.$refs.modal.$off('ok'); + }, + deleteSingleItem(index) { + this.setModalDescription(index); + + this.$refs.deleteModal.$refs.modal.$once('ok', () => { + this.removeModalEvents(); + this.handleSingleDelete(this.repo.list[index]); + }); + }, + deleteMultipleItems() { + if (this.itemsToBeDeleted.length === 1) { + this.setModalDescription(this.itemsToBeDeleted[0]); + } else if (this.itemsToBeDeleted.length > 1) { + this.setModalDescription(); + } + + this.$refs.deleteModal.$refs.modal.$once('ok', () => { + this.removeModalEvents(); + this.handleMultipleDelete(); + }); }, - handleDeleteRegistry() { - const { itemToBeDeleted } = this; - this.itemToBeDeleted = null; - this.deleteItem(itemToBeDeleted) + handleSingleDelete(itemToDelete) { + this.deleteItem(itemToDelete) .then(() => this.fetchList({ repo: this.repo })) .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY)); }, + handleMultipleDelete() { + const { itemsToBeDeleted } = this; + this.itemsToBeDeleted = []; + + if (this.bulkDeletePath) { + this.multiDeleteItems({ + path: this.bulkDeletePath, + items: itemsToBeDeleted.map(x => this.repo.list[x].tag), + }) + .then(() => this.fetchList({ repo: this.repo })) + .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY)); + } else { + this.showError(errorMessagesTypes.DELETE_REGISTRY); + } + }, onPageChange(pageNumber) { this.fetchList({ repo: this.repo, page: pageNumber }).catch(() => this.showError(errorMessagesTypes.FETCH_REGISTRY), @@ -66,6 +138,35 @@ export default { showError(message) { createFlash(errorMessages[message]); }, + onSelectAllChange() { + if (this.selectAllChecked) { + this.deselectAll(); + } else { + this.selectAll(); + } + }, + selectAll() { + this.itemsToBeDeleted = this.repo.list.map((x, index) => index); + this.selectAllChecked = true; + }, + deselectAll() { + this.itemsToBeDeleted = []; + this.selectAllChecked = false; + }, + updateItemsToBeDeleted(index) { + const delIndex = this.itemsToBeDeleted.findIndex(x => x === index); + + if (delIndex > -1) { + this.itemsToBeDeleted.splice(delIndex, 1); + this.selectAllChecked = false; + } else { + this.itemsToBeDeleted.push(index); + + if (this.itemsToBeDeleted.length === this.repo.list.length) { + this.selectAllChecked = true; + } + } + }, }, }; </script> @@ -74,15 +175,44 @@ export default { <table class="table tags"> <thead> <tr> + <th> + <gl-form-checkbox + v-if="repo.canDelete" + class="js-select-all-checkbox" + :checked="selectAllChecked" + @change="onSelectAllChange" + /> + </th> <th>{{ s__('ContainerRegistry|Tag') }}</th> <th>{{ s__('ContainerRegistry|Tag ID') }}</th> <th>{{ s__('ContainerRegistry|Size') }}</th> <th>{{ s__('ContainerRegistry|Last Updated') }}</th> - <th></th> + <th> + <gl-button + v-if="repo.canDelete" + v-gl-tooltip + v-gl-modal="modalId" + :disabled="!itemsToBeDeleted || itemsToBeDeleted.length === 0" + class="js-delete-registry float-right" + variant="danger" + :title="s__('ContainerRegistry|Remove selected images')" + :aria-label="s__('ContainerRegistry|Remove selected images')" + @click="deleteMultipleItems()" + ><icon name="remove" + /></gl-button> + </th> </tr> </thead> <tbody> - <tr v-for="item in repo.list" :key="item.tag"> + <tr v-for="(item, index) in repo.list" :key="item.tag" class="registry-image-row"> + <td class="check"> + <gl-form-checkbox + v-if="item.canDelete" + class="js-select-checkbox" + :checked="itemsToBeDeleted && itemsToBeDeleted.includes(index)" + @change="updateItemsToBeDeleted(index)" + /> + </td> <td class="monospace"> {{ item.tag }} <clipboard-button @@ -111,16 +241,15 @@ export default { </span> </td> - <td class="content"> + <td class="content action-buttons"> <gl-button v-if="item.canDelete" - v-gl-tooltip v-gl-modal="modalId" :title="s__('ContainerRegistry|Remove image')" :aria-label="s__('ContainerRegistry|Remove image')" variant="danger" - class="js-delete-registry d-none d-sm-block float-right" - @click="setItemToBeDeleted(item)" + class="js-delete-registry-row float-right btn-inverted btn-border-color btn-icon" + @click="deleteSingleItem(index)" > <icon name="remove" /> </gl-button> @@ -135,19 +264,10 @@ export default { :page-info="repo.pagination" /> - <gl-modal :modal-id="modalId" ok-variant="danger" @ok="handleDeleteRegistry"> - <template v-slot:modal-title>{{ s__('ContainerRegistry|Remove image') }}</template> - <template v-slot:modal-ok>{{ s__('ContainerRegistry|Remove image and tags') }}</template> - <p - v-html=" - sprintf( - s__( - 'ContainerRegistry|You are about to delete the image <b>%{title}</b>. This will delete the image and all tags pointing to this image.', - ), - { title: repo.name }, - ) - " - ></p> + <gl-modal ref="deleteModal" :modal-id="modalId" ok-variant="danger"> + <template v-slot:modal-title>{{ modalTitle }}</template> + <template v-slot:modal-ok>{{ s__('ContainerRegistry|Remove image(s) and tags') }}</template> + <p v-html="modalDescription"></p> </gl-modal> </div> </template> diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/stores/actions.js index 0f5e9cc73a0..a2e0130e79e 100644 --- a/app/assets/javascripts/registry/stores/actions.js +++ b/app/assets/javascripts/registry/stores/actions.js @@ -36,6 +36,8 @@ export const fetchList = ({ commit }, { repo, page }) => { }; export const deleteItem = (_, item) => axios.delete(item.destroyPath); +export const multiDeleteItems = (_, { path, items }) => + axios.delete(path, { params: { ids: items } }); export const setMainEndpoint = ({ commit }, data) => commit(types.SET_MAIN_ENDPOINT, data); export const toggleLoading = ({ commit }) => commit(types.TOGGLE_MAIN_LOADING); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue index a347269c916..53bf9d5ab6f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue @@ -23,7 +23,7 @@ export default { }; </script> <template> - <section class="mr-widget-help"> + <section class="mr-widget-help font-italic"> <template v-if="missingBranch"> {{ missingBranchInfo }} </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue index 76b96c8c1c0..8fdf61a6b8d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue @@ -18,8 +18,8 @@ export default { Deployment, MrWidgetContainer, MrWidgetPipeline, - MergeTrainInfo: () => - import('ee_component/vue_merge_request_widget/components/merge_train_info.vue'), + MergeTrainPositionIndicator: () => + import('ee_component/vue_merge_request_widget/components/merge_train_position_indicator.vue'), }, props: { mr: { @@ -62,7 +62,7 @@ export default { showVisualReviewAppLink() { return this.mr.visualReviewAppAvailable; }, - showMergeTrainInfo() { + showMergeTrainPositionIndicator() { return _.isNumber(this.mr.mergeTrainIndex); }, }, @@ -90,8 +90,8 @@ export default { :visual-review-app-meta="visualReviewAppMeta" /> </div> - <merge-train-info - v-if="showMergeTrainInfo" + <merge-train-position-indicator + v-if="showMergeTrainPositionIndicator" class="mr-widget-extension" :merge-train-index="mr.mergeTrainIndex" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index d4514767912..e294e1de976 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -94,9 +94,6 @@ export default { return __('Merge'); }, - shouldShowMergeOptionsDropdown() { - return this.isAutoMergeAvailable && !this.mr.onlyAllowMergeIfPipelineSucceeds; - }, isRemoveSourceBranchButtonDisabled() { return this.isMergeButtonDisabled; }, @@ -246,7 +243,7 @@ export default { {{ mergeButtonText }} </button> <button - v-if="isAutoMergeAvailable" + v-if="shouldShowMergeImmediatelyDropdown" :disabled="isMergeButtonDisabled" type="button" class="btn btn-sm btn-info dropdown-toggle js-merge-moment" @@ -256,7 +253,7 @@ export default { <i class="fa fa-chevron-down qa-merge-moment-dropdown" aria-hidden="true"></i> </button> <ul - v-if="shouldShowMergeOptionsDropdown" + v-if="shouldShowMergeImmediatelyDropdown" class="dropdown-menu dropdown-menu-right" role="menu" > diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js index 116d537c463..eef49e20159 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js @@ -15,5 +15,8 @@ export default { // MWPS is currently the only auto merge strategy available in CE return __('Merge when pipeline succeeds'); }, + shouldShowMergeImmediatelyDropdown() { + return this.mr.isPipelineActive && !this.mr.onlyAllowMergeIfPipelineSucceeds; + }, }, }; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 3eab8e6fc0b..0f55bebd3fc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -31,6 +31,9 @@ export default class MergeRequestStore { this.targetBranchSha = data.target_branch_sha; this.sourceBranch = data.source_branch; this.sourceBranchProtected = data.source_branch_protected; + this.conflictsDocsPath = data.conflicts_docs_path; + this.mergeRequestPipelinesHelpPath = data.merge_request_pipelines_docs_path; + this.mergeTrainWhenPipelineSucceedsDocsPath = data.merge_train_when_pipeline_succeeds_docs_path; this.mergeStatus = data.merge_status; this.commitMessage = data.default_merge_commit_message; this.shortMergeCommitSha = data.short_merge_commit_sha; diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue index cdf2d1020ba..beb2ac09992 100644 --- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue @@ -26,11 +26,6 @@ export default { required: false, default: false, }, - forceModifiedIcon: { - type: Boolean, - required: false, - default: false, - }, size: { type: Number, required: false, @@ -48,8 +43,6 @@ export default { // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings const suffix = !this.file.changed && this.file.staged && !this.showStagedIcon ? '-solid' : ''; - if (this.forceModifiedIcon) return `file-modified${suffix}`; - return `${getCommitIconMap(this.file).icon}${suffix}`; }, changedIconClass() { @@ -88,7 +81,7 @@ export default { v-gl-tooltip.right :title="tooltipTitle" :class="{ 'ml-auto': isCentered }" - class="file-changed-icon" + class="file-changed-icon d-inline-block" > <icon v-if="showIcon" :name="changedIcon" :size="size" :css-classes="changedIconClass" /> </span> diff --git a/app/assets/stylesheets/framework/callout.scss b/app/assets/stylesheets/framework/callout.scss index 643b20c56bc..c5bb2a1256a 100644 --- a/app/assets/stylesheets/framework/callout.scss +++ b/app/assets/stylesheets/framework/callout.scss @@ -9,7 +9,9 @@ .bs-callout { margin: $gl-padding 0; padding: $gl-padding; - border-left: 3px solid $border-color; + border-color: $border-color; + border-style: solid; + border-width: 0 0 0 3px; color: $text-color; background: $gray-light; @@ -48,6 +50,10 @@ background-color: $blue-100; border-color: $blue-200; color: $blue-700; + + h4 { + color: $blue-700; + } } .bs-callout-success { diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index e3dd127366d..96f6d02a68f 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -43,6 +43,7 @@ @extend .alert; background-color: $orange-100; color: $orange-900; + cursor: default; margin: 0; } diff --git a/app/assets/stylesheets/pages/container_registry.scss b/app/assets/stylesheets/pages/container_registry.scss index a21fa29f34a..0f4bdb219a3 100644 --- a/app/assets/stylesheets/pages/container_registry.scss +++ b/app/assets/stylesheets/pages/container_registry.scss @@ -31,4 +31,21 @@ .table.tags { margin-bottom: 0; + + .registry-image-row { + .check { + padding-right: $gl-padding; + width: 5%; + } + + .action-buttons { + opacity: 0; + } + + &:hover { + .action-buttons { + opacity: 1; + } + } + } } diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index 45408c9ab3c..ae92a2fbd7b 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -58,11 +58,6 @@ } } -.member-access-text { - margin-left: auto; - line-height: 43px; -} - .member-search-btn { position: absolute; right: 4px; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 3c1e384d6ed..c8d155706a9 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -397,7 +397,6 @@ .mr-widget-help { padding: 10px 16px 10px ($gl-padding-8 * 7); - font-style: italic; } .ci-coverage { @@ -906,7 +905,7 @@ } .deploy-heading, -.merge-train-info { +.merge-train-position-indicator { @include media-breakpoint-up(md) { padding: $gl-padding-8 $gl-padding; } diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss index 60400f10ca5..379df1c4db1 100644 --- a/app/assets/stylesheets/pages/wiki.scss +++ b/app/assets/stylesheets/pages/wiki.scss @@ -1,19 +1,3 @@ -.new-wiki-page { - .new-wiki-page-slug-tip { - display: inline-block; - max-width: 100%; - margin-top: 5px; - } -} - -.wiki-form { - .edit-wiki-page-slug-tip { - display: inline-block; - max-width: 100%; - margin-top: 5px; - } -} - .title .edit-wiki-header { width: 780px; margin-left: auto; @@ -22,7 +6,6 @@ } .wiki-page-header { - @extend .top-area; position: relative; .wiki-breadcrumb { diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 1d55a073f3b..af6644b8fcc 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -12,6 +12,7 @@ class ApplicationController < ActionController::Base include EnforcesTwoFactorAuthentication include WithPerformanceBar include SessionlessAuthentication + include ConfirmEmailWarning before_action :authenticate_user! before_action :enforce_terms!, if: :should_enforce_terms? @@ -116,7 +117,7 @@ class ApplicationController < ActionController::Base def render(*args) super.tap do # Set a header for custom error pages to prevent them from being intercepted by gitlab-workhorse - if response.content_type == 'text/html' && (400..599).cover?(response.status) + if (400..599).cover?(response.status) && workhorse_excluded_content_types.include?(response.content_type) response.headers['X-GitLab-Custom-Error'] = '1' end end @@ -124,6 +125,10 @@ class ApplicationController < ActionController::Base protected + def workhorse_excluded_content_types + @workhorse_excluded_content_types ||= %w(text/html application/json) + end + def append_info_to_payload(payload) super diff --git a/app/controllers/concerns/confirm_email_warning.rb b/app/controllers/concerns/confirm_email_warning.rb new file mode 100644 index 00000000000..5a4b5897a4f --- /dev/null +++ b/app/controllers/concerns/confirm_email_warning.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module ConfirmEmailWarning + extend ActiveSupport::Concern + + included do + before_action :set_confirm_warning, if: -> { Feature.enabled?(:soft_email_confirmation) } + end + + protected + + def set_confirm_warning + return unless current_user + return if current_user.confirmed? + return if peek_request? || json_request? || !request.get? + + email = current_user.unconfirmed_email || current_user.email + + flash.now[:warning] = _("Please check your email (%{email}) to verify that you own this address. Didn't receive it? %{resend_link}. Wrong email address? %{update_link}.").html_safe % { + email: email, + resend_link: view_context.link_to(_('Resend it'), user_confirmation_path(user: { email: email }), method: :post), + update_link: view_context.link_to(_('Update it'), profile_path) + } + end +end diff --git a/app/controllers/concerns/invisible_captcha.rb b/app/controllers/concerns/invisible_captcha.rb new file mode 100644 index 00000000000..c9f66e5c194 --- /dev/null +++ b/app/controllers/concerns/invisible_captcha.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module InvisibleCaptcha + extend ActiveSupport::Concern + + included do + invisible_captcha only: :create, on_spam: :on_honeypot_spam_callback, on_timestamp_spam: :on_timestamp_spam_callback + end + + def on_honeypot_spam_callback + return unless Feature.enabled?(:invisible_captcha) + + invisible_captcha_honeypot_counter.increment + log_request('Invisible_Captcha_Honeypot_Request') + + head(200) + end + + def on_timestamp_spam_callback + return unless Feature.enabled?(:invisible_captcha) + + invisible_captcha_timestamp_counter.increment + log_request('Invisible_Captcha_Timestamp_Request') + + redirect_to new_user_session_path, alert: InvisibleCaptcha.timestamp_error_message + end + + def invisible_captcha_honeypot_counter + @invisible_captcha_honeypot_counter ||= + Gitlab::Metrics.counter(:bot_blocked_by_invisible_captcha_honeypot, + 'Counter of blocked sign up attempts with filled honeypot') + end + + def invisible_captcha_timestamp_counter + @invisible_captcha_timestamp_counter ||= + Gitlab::Metrics.counter(:bot_blocked_by_invisible_captcha_timestamp, + 'Counter of blocked sign up attempts with invalid timestamp') + end + + def log_request(message) + request_information = { + message: message, + env: :invisible_captcha_signup_bot_detected, + ip: request.ip, + request_method: request.request_method, + fullpath: request.fullpath + } + + Gitlab::AuthLogger.error(request_information) + end +end diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb index 2ae500a2fdf..b192189ba3c 100644 --- a/app/controllers/confirmations_controller.rb +++ b/app/controllers/confirmations_controller.rb @@ -11,7 +11,7 @@ class ConfirmationsController < Devise::ConfirmationsController protected def after_resending_confirmation_instructions_path_for(resource) - users_almost_there_path + Feature.enabled?(:soft_email_confirmation) ? stored_location_for(resource) || dashboard_projects_path : users_almost_there_path end def after_confirmation_path_for(resource_name, resource) diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 5472ef05d7c..886d1f99d69 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -176,6 +176,7 @@ class GroupsController < Groups::ApplicationController [ :avatar, :description, + :emails_disabled, :lfs_enabled, :name, :path, diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb index 2d46a71bf99..3b0abecf2c9 100644 --- a/app/controllers/projects/cycle_analytics_controller.rb +++ b/app/controllers/projects/cycle_analytics_controller.rb @@ -14,8 +14,14 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController @cycle_analytics_no_data = @cycle_analytics.no_stats? respond_to do |format| - format.html - format.json { render json: cycle_analytics_json } + format.html do + Gitlab::UsageDataCounters::CycleAnalyticsCounter.count(:views) + + render :show + end + format.json do + render json: cycle_analytics_json + end end end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index df9e55fda2a..5a1f93dc609 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -15,7 +15,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController push_frontend_feature_flag(:environment_metrics_show_multiple_dashboards) push_frontend_feature_flag(:environment_metrics_additional_panel_types) push_frontend_feature_flag(:prometheus_computed_alerts) - push_frontend_feature_flag(:export_metrics_to_csv_enabled) end def index diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb index 956093b972b..abf8407a51c 100644 --- a/app/controllers/projects/git_http_client_controller.rb +++ b/app/controllers/projects/git_http_client_controller.rb @@ -49,7 +49,8 @@ class Projects::GitHttpClientController < Projects::ApplicationController send_final_spnego_response return # Allow access end - elsif project && download_request? && Guest.can?(:download_code, project) + elsif project && download_request? && http_allowed? && Guest.can?(:download_code, project) + @authentication_result = Gitlab::Auth::Result.new(nil, project, :none, [:download_code]) return # Allow access @@ -113,4 +114,8 @@ class Projects::GitHttpClientController < Projects::ApplicationController def ci? authentication_result.ci?(project) end + + def http_allowed? + Gitlab::ProtocolAccess.allowed?('http') + end end diff --git a/app/controllers/projects/registry/tags_controller.rb b/app/controllers/projects/registry/tags_controller.rb index bf1d8d8b5fc..54e2faa2dd7 100644 --- a/app/controllers/projects/registry/tags_controller.rb +++ b/app/controllers/projects/registry/tags_controller.rb @@ -5,6 +5,8 @@ module Projects class TagsController < ::Projects::Registry::ApplicationController before_action :authorize_destroy_container_image!, only: [:destroy] + LIMIT = 15 + def index respond_to do |format| format.json do @@ -28,10 +30,40 @@ module Projects end end + def bulk_destroy + unless params[:ids].present? + head :bad_request + return + end + + tag_names = params[:ids] || [] + if tag_names.size > LIMIT + head :bad_request + return + end + + @tags = tag_names.map { |tag_name| image.tag(tag_name) } + unless @tags.all? { |tag| tag.valid_name? } + head :bad_request + return + end + + success_count = 0 + @tags.each do |tag| + if tag.delete + success_count += 1 + end + end + + respond_to do |format| + format.json { head(success_count == @tags.size ? :no_content : :bad_request) } + end + end + private def tags - Kaminari::PaginatableArray.new(image.tags, limit: 15) + Kaminari::PaginatableArray.new(image.tags, limit: LIMIT) end def image diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index d4ff72c2314..e04cbf10470 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -361,6 +361,7 @@ class ProjectsController < Projects::ApplicationController :container_registry_enabled, :default_branch, :description, + :emails_disabled, :external_authorization_classification_label, :import_url, :issues_tracker, diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 638934694e0..e773ec09924 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -4,6 +4,7 @@ class RegistrationsController < Devise::RegistrationsController include Recaptcha::Verify include AcceptsPendingInvitations include RecaptchaExperimentHelper + include InvisibleCaptcha prepend_before_action :check_captcha, only: :create before_action :whitelist_query_limiting, only: [:destroy] @@ -68,12 +69,12 @@ class RegistrationsController < Devise::RegistrationsController def after_sign_up_path_for(user) Gitlab::AppLogger.info(user_created_message(confirmed: user.confirmed?)) - user.confirmed? ? stored_location_for(user) || dashboard_projects_path : users_almost_there_path + confirmed_or_unconfirmed_access_allowed(user) ? stored_location_or_dashboard(user) : users_almost_there_path end def after_inactive_sign_up_path_for(resource) Gitlab::AppLogger.info(user_created_message) - users_almost_there_path + Feature.enabled?(:soft_email_confirmation) ? dashboard_projects_path : users_almost_there_path end private @@ -134,4 +135,12 @@ class RegistrationsController < Devise::RegistrationsController def terms_accepted? Gitlab::Utils.to_boolean(params[:terms_opt_in]) end + + def confirmed_or_unconfirmed_access_allowed(user) + user.confirmed? || Feature.enabled?(:soft_email_confirmation) + end + + def stored_location_or_dashboard(user) + stored_location_for(user) || dashboard_projects_path + end end diff --git a/app/finders/remote_mirror_finder.rb b/app/finders/remote_mirror_finder.rb deleted file mode 100644 index 420db0077aa..00000000000 --- a/app/finders/remote_mirror_finder.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -class RemoteMirrorFinder - attr_accessor :params - - def initialize(params) - @params = params - end - - # rubocop: disable CodeReuse/ActiveRecord - def execute - RemoteMirror.find_by(id: params[:id]) - end - # rubocop: enable CodeReuse/ActiveRecord -end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index acbcf0ded17..0ab19f1d2d2 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -270,7 +270,11 @@ module ApplicationSettingsHelper :diff_max_patch_bytes, :commit_email_hostname, :protected_ci_variables, - :local_markdown_version + :local_markdown_version, + :snowplow_collector_hostname, + :snowplow_cookie_domain, + :snowplow_enabled, + :snowplow_site_id ] end diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb new file mode 100644 index 00000000000..af98a611b8b --- /dev/null +++ b/app/helpers/sessions_helper.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module SessionsHelper + def unconfirmed_email? + flash[:alert] == t(:unconfirmed, scope: [:devise, :failure]) + end +end diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index 645160077f5..38142bc68cb 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -26,7 +26,7 @@ module TodosHelper end def todo_target_link(todo) - text = raw("#{todo.target_type.titleize.downcase} ") + + text = raw(todo_target_type_name(todo) + ' ') + if todo.for_commit? content_tag(:span, todo.target_reference, class: 'commit-sha') else @@ -36,23 +36,34 @@ module TodosHelper link_to text, todo_target_path(todo), class: 'has-tooltip', title: todo.target.title end + def todo_target_type_name(todo) + todo.target_type.titleize.downcase + end + def todo_target_path(todo) return unless todo.target.present? - anchor = dom_id(todo.note) if todo.note.present? + path_options = todo_target_path_options(todo) if todo.for_commit? - project_commit_path(todo.project, - todo.target, anchor: anchor) + project_commit_path(todo.project, todo.target, path_options) else path = [todo.parent, todo.target] path.unshift(:pipelines) if todo.build_failed? - polymorphic_path(path, anchor: anchor) + polymorphic_path(path, path_options) end end + def todo_target_path_options(todo) + { anchor: todo_target_path_anchor(todo) } + end + + def todo_target_path_anchor(todo) + dom_id(todo.note) if todo.note.present? + end + def todo_target_state_pill(todo) return unless show_todo_state?(todo) diff --git a/app/helpers/tracking_helper.rb b/app/helpers/tracking_helper.rb index 51ea79d1ddd..221d9692661 100644 --- a/app/helpers/tracking_helper.rb +++ b/app/helpers/tracking_helper.rb @@ -2,6 +2,21 @@ module TrackingHelper def tracking_attrs(label, event, property) - {} # CE has no tracking features + return {} unless tracking_enabled? + + { + data: { + track_label: label, + track_event: event, + track_property: property + } + } + end + + private + + def tracking_enabled? + Rails.env.production? && + ::Gitlab::CurrentSettings.snowplow_enabled? end end diff --git a/app/mailers/emails/remote_mirrors.rb b/app/mailers/emails/remote_mirrors.rb index 2d8137843ec..f3938a052b0 100644 --- a/app/mailers/emails/remote_mirrors.rb +++ b/app/mailers/emails/remote_mirrors.rb @@ -3,7 +3,7 @@ module Emails module RemoteMirrors def remote_mirror_update_failed_email(remote_mirror_id, recipient_id) - @remote_mirror = RemoteMirrorFinder.new(id: remote_mirror_id).execute + @remote_mirror = RemoteMirror.find_by_id(remote_mirror_id) @project = @remote_mirror.project mail(to: recipient(recipient_id, @project.group), subject: subject('Remote mirror update failed')) diff --git a/app/models/analytics/cycle_analytics.rb b/app/models/analytics/cycle_analytics.rb new file mode 100644 index 00000000000..626fc91cc41 --- /dev/null +++ b/app/models/analytics/cycle_analytics.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Analytics + module CycleAnalytics + def self.table_name_prefix + 'analytics_cycle_analytics_' + end + end +end diff --git a/app/models/analytics/cycle_analytics/project_stage.rb b/app/models/analytics/cycle_analytics/project_stage.rb new file mode 100644 index 00000000000..88c8cb40ccb --- /dev/null +++ b/app/models/analytics/cycle_analytics/project_stage.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Analytics + module CycleAnalytics + class ProjectStage < ApplicationRecord + belongs_to :project + end + end +end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index cb6346421ec..2a99c6e5c59 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -99,6 +99,11 @@ class ApplicationSetting < ApplicationRecord presence: true, if: :plantuml_enabled + validates :snowplow_collector_hostname, + presence: true, + hostname: true, + if: :snowplow_enabled + validates :max_attachment_size, presence: true, numericality: { only_integer: true, greater_than: 0 } diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index b7a4d7aa803..55ac1e129cf 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -97,6 +97,10 @@ module ApplicationSettingImplementation usage_stats_set_by_user_id: nil, diff_max_patch_bytes: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES, commit_email_hostname: default_commit_email_hostname, + snowplow_collector_hostname: nil, + snowplow_cookie_domain: nil, + snowplow_enabled: false, + snowplow_site_id: nil, protected_ci_variables: false, local_markdown_version: 0, outbound_local_requests_whitelist: [], diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index ac88d9714ac..3c0efca31db 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -384,7 +384,7 @@ module Ci return unless has_environment? strong_memoize(:expanded_environment_name) do - ExpandVariables.expand(environment, simple_variables) + ExpandVariables.expand(environment, -> { simple_variables }) end end @@ -716,7 +716,7 @@ module Ci depended_jobs = depends_on_builds # find all jobs that are needed - if Feature.enabled?(:ci_dag_support, project) && needs.exists? + if Feature.enabled?(:ci_dag_support, project, default_enabled: true) && needs.exists? depended_jobs = depended_jobs.where(name: needs.select(:name)) end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 3b28eb246db..0a943a33bbb 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -328,6 +328,10 @@ module Ci config_sources.values_at(:repository_source, :auto_devops_source, :unknown_source) end + def self.bridgeable_statuses + ::Ci::Pipeline::AVAILABLE_STATUSES - %w[created preparing pending] + end + def stages_count statuses.select(:stage).distinct.count end diff --git a/app/models/clusters/applications/cert_manager.rb b/app/models/clusters/applications/cert_manager.rb index 2fc1b67dfd2..6bd7473c8ff 100644 --- a/app/models/clusters/applications/cert_manager.rb +++ b/app/models/clusters/applications/cert_manager.rb @@ -64,11 +64,15 @@ module Clusters end def delete_private_key - "kubectl delete secret -n #{Gitlab::Kubernetes::Helm::NAMESPACE} #{private_key_name} --ignore-not-found" if private_key_name.present? + return unless private_key_name.present? + + args = %W(secret -n #{Gitlab::Kubernetes::Helm::NAMESPACE} #{private_key_name} --ignore-not-found) + + Gitlab::Kubernetes::KubectlCmd.delete(*args) end def delete_crd(definition) - "kubectl delete crd #{definition} --ignore-not-found" + Gitlab::Kubernetes::KubectlCmd.delete("crd", definition, "--ignore-not-found") end def cluster_issuer_file diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb index 5eae23659ae..244fe738396 100644 --- a/app/models/clusters/applications/knative.rb +++ b/app/models/clusters/applications/knative.rb @@ -89,7 +89,7 @@ module Clusters def delete_knative_services cluster.kubernetes_namespaces.map do |kubernetes_namespace| - "kubectl delete ksvc --all -n #{kubernetes_namespace.namespace}" + Gitlab::Kubernetes::KubectlCmd.delete("ksvc", "--all", "-n", kubernetes_namespace.namespace) end end @@ -99,14 +99,14 @@ module Clusters def delete_knative_namespaces [ - "kubectl delete --ignore-not-found ns knative-serving", - "kubectl delete --ignore-not-found ns knative-build" + Gitlab::Kubernetes::KubectlCmd.delete("--ignore-not-found", "ns", "knative-serving"), + Gitlab::Kubernetes::KubectlCmd.delete("--ignore-not-found", "ns", "knative-build") ] end def delete_knative_and_istio_crds api_resources.map do |crd| - "kubectl delete --ignore-not-found crd #{crd}" + Gitlab::Kubernetes::KubectlCmd.delete("--ignore-not-found", "crd", "#{crd}") end end @@ -119,13 +119,13 @@ module Clusters def install_knative_metrics return [] unless cluster.application_prometheus_available? - ["kubectl apply -f #{METRICS_CONFIG}"] + [Gitlab::Kubernetes::KubectlCmd.apply_file(METRICS_CONFIG)] end def delete_knative_istio_metrics return [] unless cluster.application_prometheus_available? - ["kubectl delete --ignore-not-found -f #{METRICS_CONFIG}"] + [Gitlab::Kubernetes::KubectlCmd.delete("--ignore-not-found", "-f", METRICS_CONFIG)] end def verify_cluster? diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index 08e52f32bb3..f31a6b8b50e 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -106,13 +106,13 @@ module Clusters def install_knative_metrics return [] unless cluster.application_knative_available? - ["kubectl apply -f #{Clusters::Applications::Knative::METRICS_CONFIG}"] + [Gitlab::Kubernetes::KubectlCmd.apply_file(Clusters::Applications::Knative::METRICS_CONFIG)] end def delete_knative_istio_metrics return [] unless cluster.application_knative_available? - ["kubectl delete -f #{Clusters::Applications::Knative::METRICS_CONFIG}"] + [Gitlab::Kubernetes::KubectlCmd.delete("-f", Clusters::Applications::Knative::METRICS_CONFIG)] end end end diff --git a/app/models/clusters/clusters_hierarchy.rb b/app/models/clusters/clusters_hierarchy.rb index dab034b7234..5556fc8d3f0 100644 --- a/app/models/clusters/clusters_hierarchy.rb +++ b/app/models/clusters/clusters_hierarchy.rb @@ -46,7 +46,7 @@ module Clusters def group_clusters_base_query group_parent_id_alias = alias_as_column(groups[:parent_id], 'group_parent_id') - join_sources = ::Group.left_joins(:clusters).join_sources + join_sources = ::Group.left_joins(:clusters).arel.join_sources model .unscoped @@ -59,7 +59,7 @@ module Clusters def project_clusters_base_query projects = ::Project.arel_table project_parent_id_alias = alias_as_column(projects[:namespace_id], 'group_parent_id') - join_sources = ::Project.left_joins(:clusters).join_sources + join_sources = ::Project.left_joins(:clusters).arel.join_sources model .unscoped diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index a88cac6b8e6..4be4d95b4a1 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -40,8 +40,11 @@ class CommitStatus < ApplicationRecord scope :ordered, -> { order(:name) } scope :latest_ordered, -> { latest.ordered.includes(project: :namespace) } scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) } + scope :before_stage, -> (index) { where('stage_idx < ?', index) } + scope :for_stage, -> (index) { where(stage_idx: index) } scope :after_stage, -> (index) { where('stage_idx > ?', index) } scope :processables, -> { where(type: %w[Ci::Build Ci::Bridge]) } + scope :for_ids, -> (ids) { where(id: ids) } scope :with_needs, -> (names = nil) do needs = Ci::BuildNeed.scoped_build.select(1) @@ -49,8 +52,10 @@ class CommitStatus < ApplicationRecord where('EXISTS (?)', needs).preload(:needs) end - scope :without_needs, -> do - where('NOT EXISTS (?)', Ci::BuildNeed.scoped_build.select(1)) + scope :without_needs, -> (names = nil) do + needs = Ci::BuildNeed.scoped_build.select(1) + needs = needs.where(name: names) if names + where('NOT EXISTS (?)', needs) end # We use `CommitStatusEnums.failure_reasons` here so that EE can more easily @@ -149,6 +154,18 @@ class CommitStatus < ApplicationRecord end end + def self.names + select(:name) + end + + def self.status_for_prior_stages(index) + before_stage(index).latest.status || 'success' + end + + def self.status_for_names(names) + where(name: names).latest.status || 'success' + end + def locking_enabled? will_save_change_to_status? end diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb index 27a5c3d5286..71ebb586c13 100644 --- a/app/models/concerns/has_status.rb +++ b/app/models/concerns/has_status.rb @@ -106,10 +106,15 @@ module HasStatus scope :running_or_pending, -> { with_status(:running, :pending) } scope :finished, -> { with_status(:success, :failed, :canceled) } scope :failed_or_canceled, -> { with_status(:failed, :canceled) } + scope :incomplete, -> { without_statuses(completed_statuses) } scope :cancelable, -> do where(status: [:running, :preparing, :pending, :created, :scheduled]) end + + scope :without_statuses, -> (names) do + with_status(all_state_names - names.to_a) + end end def started? diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 4306dd9266f..bfd636fa62a 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -220,18 +220,7 @@ class MergeRequest < ApplicationRecord end def rebase_in_progress? - (rebase_jid.present? && Gitlab::SidekiqStatus.running?(rebase_jid)) || - gitaly_rebase_in_progress? - end - - # TODO: remove the Gitaly lookup after v12.1, when rebase_jid will be reliable - def gitaly_rebase_in_progress? - strong_memoize(:gitaly_rebase_in_progress) do - # The source project can be deleted - next false unless source_project - - source_project.repository.rebase_in_progress?(id) - end + rebase_jid.present? && Gitlab::SidekiqStatus.running?(rebase_jid) end # Use this method whenever you need to make sure the head_pipeline is synced with the diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 058350b16ce..9f9c4288667 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -172,6 +172,13 @@ class Namespace < ApplicationRecord end end + # any ancestor can disable emails for all descendants + def emails_disabled? + strong_memoize(:emails_disabled) do + Feature.enabled?(:emails_disabled, self, default_enabled: true) && self_and_ancestors.where(emails_disabled: true).exists? + end + end + def lfs_enabled? # User namespace will always default to the global setting Gitlab.config.lfs.enabled diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb index a7f73c0f29c..8e44e3d8e17 100644 --- a/app/models/notification_recipient.rb +++ b/app/models/notification_recipient.rb @@ -4,6 +4,7 @@ class NotificationRecipient include Gitlab::Utils::StrongMemoize attr_reader :user, :type, :reason + def initialize(user, type, **opts) unless NotificationSetting.levels.key?(type) || type == :subscription raise ArgumentError, "invalid type: #{type.inspect}" @@ -30,6 +31,7 @@ class NotificationRecipient def notifiable? return false unless has_access? + return false if emails_disabled? return false if own_activity? # even users with :disabled notifications receive manual subscriptions @@ -109,6 +111,12 @@ class NotificationRecipient private + # They are disabled if the project or group has disallowed it. + # No need to check the group if there is already a project + def emails_disabled? + @project ? @project.emails_disabled? : @group&.emails_disabled? + end + def read_ability return if @skip_read_ability return @read_ability if instance_variable_defined?(:@read_ability) diff --git a/app/models/project.rb b/app/models/project.rb index a6e43efa1f3..8efe4b06f87 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -283,6 +283,7 @@ class Project < ApplicationRecord has_one :ci_cd_settings, class_name: 'ProjectCiCdSetting', inverse_of: :project, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :remote_mirrors, inverse_of: :project + has_many :cycle_analytics_stages, class_name: 'Analytics::CycleAnalytics::ProjectStage' accepts_nested_attributes_for :variables, allow_destroy: true accepts_nested_attributes_for :project_feature, update_only: true @@ -631,6 +632,13 @@ class Project < ApplicationRecord alias_method :ancestors, :ancestors_upto + def emails_disabled? + strong_memoize(:emails_disabled) do + # disabling in the namespace overrides the project setting + Feature.enabled?(:emails_disabled, self, default_enabled: true) && (super || namespace.emails_disabled?) + end + end + def lfs_enabled? return namespace.lfs_enabled? if self[:lfs_enabled].nil? @@ -1230,6 +1238,14 @@ class Project < ApplicationRecord end end + def has_active_hooks?(hooks_scope = :push_hooks) + hooks.hooks_for(hooks_scope).any? || SystemHook.hooks_for(hooks_scope).any? + end + + def has_active_services?(hooks_scope = :push_hooks) + services.public_send(hooks_scope).any? # rubocop:disable GitlabSecurity/PublicSend + end + def valid_repo? repository.exists? rescue diff --git a/app/models/project_services/chat_message/pipeline_message.rb b/app/models/project_services/chat_message/pipeline_message.rb index 4edf263433f..a3793d9937b 100644 --- a/app/models/project_services/chat_message/pipeline_message.rb +++ b/app/models/project_services/chat_message/pipeline_message.rb @@ -68,7 +68,7 @@ module ChatMessage title_link: pipeline_url, fields: attachments_fields, footer: project.name, - footer_icon: project.avatar_url, + footer_icon: project.avatar_url(only_path: false), ts: finished_at }] end diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb index 45de64a9990..8ca40138a8f 100644 --- a/app/models/project_services/emails_on_push_service.rb +++ b/app/models/project_services/emails_on_push_service.rb @@ -24,6 +24,7 @@ class EmailsOnPushService < Service def execute(push_data) return unless supported_events.include?(push_data[:object_kind]) + return if project.emails_disabled? EmailsOnPushWorker.perform_async( project_id, diff --git a/app/models/project_services/slash_commands_service.rb b/app/models/project_services/slash_commands_service.rb index 5f5cff97808..cb16ad75d14 100644 --- a/app/models/project_services/slash_commands_service.rb +++ b/app/models/project_services/slash_commands_service.rb @@ -35,6 +35,8 @@ class SlashCommandsService < Service chat_user = find_chat_user(params) if chat_user&.user + return Gitlab::SlashCommands::Presenters::Access.new.access_denied unless chat_user.user.can?(:use_slash_commands) + Gitlab::SlashCommands::Command.new(project, chat_user, params).execute else url = authorize_chat_name_url(params) diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb index 6b5605f9999..c9ee0653d86 100644 --- a/app/models/remote_mirror.rb +++ b/app/models/remote_mirror.rb @@ -4,6 +4,8 @@ class RemoteMirror < ApplicationRecord include AfterCommitQueue include MirrorAuthentication + MAX_FIRST_RUNTIME = 3.hours + MAX_INCREMENTAL_RUNTIME = 1.hour PROTECTED_BACKOFF_DELAY = 1.minute UNPROTECTED_BACKOFF_DELAY = 5.minutes @@ -31,11 +33,18 @@ class RemoteMirror < ApplicationRecord scope :enabled, -> { where(enabled: true) } scope :started, -> { with_update_status(:started) } - scope :stuck, -> { started.where('last_update_at < ? OR (last_update_at IS NULL AND updated_at < ?)', 1.hour.ago, 3.hours.ago) } + + scope :stuck, -> do + started + .where('(last_update_started_at < ? AND last_update_at IS NOT NULL)', + MAX_INCREMENTAL_RUNTIME.ago) + .or(where('(last_update_started_at < ? AND last_update_at IS NULL)', + MAX_FIRST_RUNTIME.ago)) + end state_machine :update_status, initial: :none do event :update_start do - transition [:none, :finished, :failed] => :started + transition any => :started end event :update_finish do @@ -46,9 +55,14 @@ class RemoteMirror < ApplicationRecord transition started: :failed end + event :update_retry do + transition started: :to_retry + end + state :started state :finished state :failed + state :to_retry after_transition any => :started do |remote_mirror, _| Gitlab::Metrics.add_event(:remote_mirrors_running) @@ -138,16 +152,27 @@ class RemoteMirror < ApplicationRecord end def updated_since?(timestamp) - last_update_started_at && last_update_started_at > timestamp && !update_failed? + return false if failed? + + last_update_started_at && last_update_started_at > timestamp end def mark_for_delete_if_blank_url mark_for_destruction if url.blank? end - def mark_as_failed(error_message) - update_column(:last_error, Gitlab::UrlSanitizer.sanitize(error_message)) - update_fail + def update_error_message(error_message) + self.last_error = Gitlab::UrlSanitizer.sanitize(error_message) + end + + def mark_for_retry!(error_message) + update_error_message(error_message) + update_retry! + end + + def mark_as_failed!(error_message) + update_error_message(error_message) + update_fail! end def url=(value) @@ -190,6 +215,18 @@ class RemoteMirror < ApplicationRecord update_column(:error_notification_sent, true) end + def backoff_delay + if self.only_protected_branches + PROTECTED_BACKOFF_DELAY + else + UNPROTECTED_BACKOFF_DELAY + end + end + + def max_runtime + last_update_at.present? ? MAX_INCREMENTAL_RUNTIME : MAX_FIRST_RUNTIME + end + private def store_credentials @@ -219,14 +256,6 @@ class RemoteMirror < ApplicationRecord self.last_update_started_at >= Time.now - backoff_delay end - def backoff_delay - if self.only_protected_branches - PROTECTED_BACKOFF_DELAY - else - UNPROTECTED_BACKOFF_DELAY - end - end - def reset_fields update_columns( last_error: nil, diff --git a/app/models/repository.rb b/app/models/repository.rb index 58abfaef801..6f63cd32da4 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -389,11 +389,15 @@ class Repository expire_statistics_caches end - # Runs code after a repository has been created. - def after_create + def expire_status_cache expire_exists_cache expire_root_ref_cache expire_emptiness_caches + end + + # Runs code after a repository has been created. + def after_create + expire_status_cache repository_event(:create_repository) end @@ -418,25 +422,29 @@ class Repository end # Runs code before pushing (= creating or removing) a tag. + # + # Note that this doesn't expire the tags. You may need to call + # expire_caches_for_tags or expire_tags_cache. def before_push_tag + repository_event(:push_tag) + end + + def expire_caches_for_tags expire_statistics_caches expire_emptiness_caches expire_tags_cache - - repository_event(:push_tag) end # Runs code before removing a tag. def before_remove_tag - expire_tags_cache - expire_statistics_caches + expire_caches_for_tags repository_event(:remove_tag) end # Runs code after removing a tag. def after_remove_tag - expire_tags_cache + expire_caches_for_tags end # Runs code after the HEAD of a repository is changed. diff --git a/app/models/user.rb b/app/models/user.rb index 374e00987c5..6131a8dc710 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1507,6 +1507,13 @@ class User < ApplicationRecord super end + # override from Devise::Confirmable + def confirmation_period_valid? + return false if Feature.disabled?(:soft_email_confirmation) + + super + end + private def default_private_profile_to_false diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index 134de1c9ace..311aab0dcd4 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -33,6 +33,7 @@ class GlobalPolicy < BasePolicy enable :access_git enable :receive_notifications enable :use_quick_actions + enable :use_slash_commands end rule { blocked | internal }.policy do @@ -40,6 +41,7 @@ class GlobalPolicy < BasePolicy prevent :access_api prevent :access_git prevent :receive_notifications + prevent :use_slash_commands end rule { required_terms_not_accepted }.policy do @@ -57,6 +59,7 @@ class GlobalPolicy < BasePolicy rule { access_locked }.policy do prevent :log_in + prevent :use_slash_commands end rule { ~(anonymous & restricted_public_level) }.policy do diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 52c944491bf..c686e7763bb 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -92,6 +92,7 @@ class GroupPolicy < BasePolicy enable :change_visibility_level enable :set_note_created_at + enable :set_emails_disabled end rule { can?(:read_nested_project_resources) }.policy do diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index e79bac6bee3..b8dee1b0789 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -162,6 +162,7 @@ class ProjectPolicy < BasePolicy enable :set_issue_created_at enable :set_issue_updated_at enable :set_note_created_at + enable :set_emails_disabled end rule { can?(:guest_access) }.policy do diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb index 943c707218d..6e91317eb20 100644 --- a/app/serializers/deployment_entity.rb +++ b/app/serializers/deployment_entity.rb @@ -18,6 +18,7 @@ class DeploymentEntity < Grape::Entity end expose :created_at + expose :finished_at expose :tag expose :last? expose :user, using: UserEntity diff --git a/app/serializers/deployment_serializer.rb b/app/serializers/deployment_serializer.rb index 04db6b88489..3fd3e1b9cc8 100644 --- a/app/serializers/deployment_serializer.rb +++ b/app/serializers/deployment_serializer.rb @@ -4,7 +4,7 @@ class DeploymentSerializer < BaseSerializer entity DeploymentEntity def represent_concise(resource, opts = {}) - opts[:only] = [:iid, :id, :sha, :created_at, :tag, :last?, :id, ref: [:name]] + opts[:only] = [:iid, :id, :sha, :created_at, :finished_at, :tag, :last?, :id, ref: [:name]] represent(resource, opts) end end diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb index 99d4ff9ecd1..3b145a65d79 100644 --- a/app/services/ci/process_pipeline_service.rb +++ b/app/services/ci/process_pipeline_service.rb @@ -40,16 +40,21 @@ module Ci def process_builds_with_needs(trigger_build_ids) return false unless trigger_build_ids.present? - return false unless Feature.enabled?(:ci_dag_support, project) + return false unless Feature.enabled?(:ci_dag_support, project, default_enabled: true) - # rubocop: disable CodeReuse/ActiveRecord - trigger_build_names = pipeline.statuses - .where(id: trigger_build_ids) - .select(:name) - # rubocop: enable CodeReuse/ActiveRecord + # we find processables that are dependent: + # 1. because of current dependency, + trigger_build_names = pipeline.processables.latest + .for_ids(trigger_build_ids).names + # 2. does not have builds that not yet complete + incomplete_build_names = pipeline.processables.latest + .incomplete.names + + # Each found processable is guaranteed here to have completed status created_processables .with_needs(trigger_build_names) + .without_needs(incomplete_build_names) .find_each .map(&method(:process_build_with_needs)) .any? @@ -70,17 +75,13 @@ module Ci end end - # rubocop: disable CodeReuse/ActiveRecord def status_for_prior_stages(index) - pipeline.builds.where('stage_idx < ?', index).latest.status || 'success' + pipeline.processables.status_for_prior_stages(index) end - # rubocop: enable CodeReuse/ActiveRecord - # rubocop: disable CodeReuse/ActiveRecord def status_for_build_needs(needs) - pipeline.builds.where(name: needs).latest.status || 'success' + pipeline.processables.status_for_names(needs) end - # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord def stage_indexes_of_created_processables_without_needs @@ -89,15 +90,13 @@ module Ci end # rubocop: enable CodeReuse/ActiveRecord - # rubocop: disable CodeReuse/ActiveRecord def created_processables_in_stage_without_needs(index) created_processables_without_needs - .where(stage_idx: index) + .for_stage(index) end - # rubocop: enable CodeReuse/ActiveRecord def created_processables_without_needs - if Feature.enabled?(:ci_dag_support, project) + if Feature.enabled?(:ci_dag_support, project, default_enabled: true) pipeline.processables.created.without_needs else pipeline.processables.created diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb index d30df34e54b..3fd38444196 100644 --- a/app/services/git/base_hooks_service.rb +++ b/app/services/git/base_hooks_service.rb @@ -8,8 +8,6 @@ module Git PROCESS_COMMIT_LIMIT = 100 def execute - project.repository.after_create if project.empty_repo? - create_events create_pipelines execute_project_hooks @@ -19,7 +17,7 @@ module Git update_remote_mirrors - push_data + success end private @@ -33,7 +31,7 @@ module Git end def limited_commits - commits.last(PROCESS_COMMIT_LIMIT) + @limited_commits ||= commits.last(PROCESS_COMMIT_LIMIT) end def commits_count @@ -48,43 +46,64 @@ module Git [] end + # Push events in the activity feed only show information for the + # last commit. def create_events - EventCreateService.new.push(project, current_user, push_data) + EventCreateService.new.push(project, current_user, event_push_data) end def create_pipelines return unless params.fetch(:create_pipelines, true) Ci::CreatePipelineService - .new(project, current_user, push_data) + .new(project, current_user, base_params) .execute(:push, pipeline_options) end def execute_project_hooks - project.execute_hooks(push_data, hook_name) - project.execute_services(push_data, hook_name) + # Creating push_data invokes one CommitDelta RPC per commit. Only + # build this data if we actually need it. + project.execute_hooks(push_data, hook_name) if project.has_active_hooks?(hook_name) + project.execute_services(push_data, hook_name) if project.has_active_services?(hook_name) end def enqueue_invalidate_cache - ProjectCacheWorker.perform_async( - project.id, - invalidated_file_types, - [:commit_count, :repository_size] - ) + file_types = invalidated_file_types + + return unless file_types.present? + + ProjectCacheWorker.perform_async(project.id, file_types, [], false) end - def push_data - @push_data ||= Gitlab::DataBuilder::Push.build( - project: project, - user: current_user, + def base_params + { oldrev: params[:oldrev], newrev: params[:newrev], ref: params[:ref], - commits: limited_commits, + push_options: params[:push_options] || {} + } + end + + def push_data_params(commits:, with_changed_files: true) + base_params.merge( + project: project, + user: current_user, + commits: commits, message: event_message, commits_count: commits_count, - push_options: params[:push_options] || {} + with_changed_files: with_changed_files ) + end + + def event_push_data + # We only need the last commit for the event push, and we don't + # need the full deltas either. + @event_push_data ||= Gitlab::DataBuilder::Push.build( + push_data_params(commits: commits.last, with_changed_files: false)) + end + + def push_data + @push_data ||= Gitlab::DataBuilder::Push.build(push_data_params(commits: limited_commits)) # Dependent code may modify the push data, so return a duplicate each time @push_data.dup diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index 73e1e00dc33..116756bacfe 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -46,6 +46,11 @@ module Groups params.delete(:parent_id) end + # overridden in EE + def remove_unallowed_params + params.delete(:emails_disabled) unless can?(current_user, :set_emails_disabled, group) + end + def valid_share_with_group_lock_change? return true unless changing_share_with_group_lock? return true if can?(current_user, :change_share_with_group_lock, group) diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb index 8d3b9b05819..27c16ba1777 100644 --- a/app/services/merge_requests/rebase_service.rb +++ b/app/services/merge_requests/rebase_service.rb @@ -15,7 +15,8 @@ module MergeRequests end def rebase - if merge_request.gitaly_rebase_in_progress? + # Ensure Gitaly isn't already running a rebase + if source_project.repository.rebase_in_progress?(merge_request.id) log_error('Rebase task canceled: Another rebase is already in progress', save_message_on_model: true) return false end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 21fab22e0d4..83710ffce2f 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -321,6 +321,9 @@ class NotificationService end def decline_project_invite(project_member) + # Must always send, regardless of project/namespace configuration since it's a + # response to the user's action. + mailer.member_invite_declined_email( project_member.real_source_type, project_member.project.id, @@ -351,8 +354,8 @@ class NotificationService end def decline_group_invite(group_member) - # always send this one, since it's a response to the user's own - # action + # Must always send, regardless of project/namespace configuration since it's a + # response to the user's action. mailer.member_invite_declined_email( group_member.real_source_type, @@ -410,6 +413,10 @@ class NotificationService end def pipeline_finished(pipeline, recipients = nil) + # Must always check project configuration since recipients could be a list of emails + # from the PipelinesEmailService integration. + return if pipeline.project.emails_disabled? + email_template = "pipeline_#{pipeline.status}_email" return unless mailer.respond_to?(email_template) @@ -428,6 +435,8 @@ class NotificationService end def autodevops_disabled(pipeline, recipients) + return if pipeline.project.emails_disabled? + recipients.each do |recipient| mailer.autodevops_disabled_email(pipeline, recipient).deliver_later end @@ -472,10 +481,14 @@ class NotificationService end def repository_cleanup_success(project, user) + return if project.emails_disabled? + mailer.send(:repository_cleanup_success_email, project, user).deliver_later end def repository_cleanup_failure(project, user, error) + return if project.emails_disabled? + mailer.send(:repository_cleanup_failure_email, project, user, error).deliver_later end diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb index 1244a0f72a7..13a467a3ef9 100644 --- a/app/services/projects/update_remote_mirror_service.rb +++ b/app/services/projects/update_remote_mirror_service.rb @@ -2,31 +2,52 @@ module Projects class UpdateRemoteMirrorService < BaseService - attr_reader :errors + MAX_TRIES = 3 - def execute(remote_mirror) + def execute(remote_mirror, tries) return success unless remote_mirror.enabled? - errors = [] + update_mirror(remote_mirror) - begin - remote_mirror.ensure_remote! - repository.fetch_remote(remote_mirror.remote_name, ssh_auth: remote_mirror, no_tags: true) + success + rescue Gitlab::Git::CommandError => e + # This happens if one of the gitaly calls above fail, for example when + # branches have diverged, or the pre-receive hook fails. + retry_or_fail(remote_mirror, e.message, tries) - opts = {} - if remote_mirror.only_protected_branches? - opts[:only_branches_matching] = project.protected_branches.select(:name).map(&:name) - end + error(e.message) + rescue => e + remote_mirror.mark_as_failed!(e.message) + raise e + end + + private + + def update_mirror(remote_mirror) + remote_mirror.update_start! + + remote_mirror.ensure_remote! + repository.fetch_remote(remote_mirror.remote_name, ssh_auth: remote_mirror, no_tags: true) - remote_mirror.update_repository(opts) - rescue => e - errors << e.message.strip + opts = {} + if remote_mirror.only_protected_branches? + opts[:only_branches_matching] = project.protected_branches.select(:name).map(&:name) end - if errors.present? - error(errors.join("\n\n")) + remote_mirror.update_repository(opts) + + remote_mirror.update_finish! + end + + def retry_or_fail(mirror, message, tries) + if tries < MAX_TRIES + mirror.mark_for_retry!(message) else - success + # It's not likely we'll be able to recover from this ourselves, so we'll + # notify the users of the problem, and don't trigger any sidekiq retries + # Instead, we'll wait for the next change to try the push again, or until + # a user manually retries. + mirror.mark_as_failed!(message) end end end diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index caab946174d..8acbdc7e02b 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -9,6 +9,7 @@ module Projects # rubocop: disable CodeReuse/ActiveRecord def execute + remove_unallowed_params validate! ensure_wiki_exists if enabling_wiki? @@ -54,6 +55,10 @@ module Projects end end + def remove_unallowed_params + params.delete(:emails_disabled) unless can?(current_user, :set_emails_disabled, project) + end + def after_update todos_features_changes = %w( issues_access_level diff --git a/app/services/update_deployment_service.rb b/app/services/update_deployment_service.rb index 49a7d0178f4..dcafebae52d 100644 --- a/app/services/update_deployment_service.rb +++ b/app/services/update_deployment_service.rb @@ -42,7 +42,7 @@ class UpdateDeploymentService return unless environment_url @expanded_environment_url = - ExpandVariables.expand(environment_url, variables) + ExpandVariables.expand(environment_url, -> { variables }) end def environment_url diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml new file mode 100644 index 00000000000..b60b5d55a1b --- /dev/null +++ b/app/views/admin/application_settings/_snowplow.html.haml @@ -0,0 +1,30 @@ +- expanded = true if !@application_setting.valid? && @application_setting.errors.any? { |k| k.to_s.start_with?('snowplow_') } +%section.settings.as-snowplow.no-animate#js-snowplow-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Snowplow') + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + = _('Configure the %{link} integration.').html_safe % { link: link_to('Snowplow', 'https://snowplowanalytics.com/', target: '_blank') } + .settings-content + + = form_for @application_setting, url: integrations_admin_application_settings_path, html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .form-check + = f.check_box :snowplow_enabled, class: 'form-check-input' + = f.label :snowplow_enabled, _('Enable snowplow tracking'), class: 'form-check-label' + .form-group + = f.label :snowplow_collector_hostname, _('Collector hostname'), class: 'label-light' + = f.text_field :snowplow_collector_hostname, class: 'form-control', placeholder: 'snowplow.example.com' + .form-group + = f.label :snowplow_site_id, _('Site ID'), class: 'label-light' + = f.text_field :snowplow_site_id, class: 'form-control' + .form-group + = f.label :snowplow_cookie_domain, _('Cookie domain'), class: 'label-light' + = f.text_field :snowplow_cookie_domain, class: 'form-control' + + = f.submit _('Save changes'), class: 'btn btn-success' diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml index 2f10f08c839..0b1d3d1ddb3 100644 --- a/app/views/devise/sessions/_new_base.html.haml +++ b/app/views/devise/sessions/_new_base.html.haml @@ -1,20 +1,23 @@ = form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors', 'aria-live' => 'assertive'}) do |f| .form-group - = f.label "Username or email", for: "user_login", class: 'label-bold' - = f.text_field :login, class: "form-control top", autofocus: "autofocus", autocapitalize: "off", autocorrect: "off", required: true, title: "This field is required.", data: { qa_selector: 'login_field' } + = f.label _('Username or email'), for: 'user_login', class: 'label-bold' + = f.text_field :login, class: 'form-control top', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field' } .form-group = f.label :password, class: 'label-bold' - = f.password_field :password, class: "form-control bottom", required: true, title: "This field is required.", data: { qa_selector: 'password_field' } + = f.password_field :password, class: 'form-control bottom', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' } - if devise_mapping.rememberable? .remember-me - %label{ for: "user_remember_me" } + %label{ for: 'user_remember_me' } = f.check_box :remember_me, class: 'remember-me-checkbox' %span Remember me - .float-right.forgot-password - = link_to "Forgot your password?", new_password_path(:user) + .float-right + - if unconfirmed_email? + = link_to _('Resend confirmation email'), new_user_confirmation_path + - else + = link_to _('Forgot your password?'), new_password_path(:user) %div - if captcha_enabled? = recaptcha_tags .submit-container.move-submit-down - = f.submit "Sign in", class: "btn btn-success", data: { qa_selector: 'sign_in_button' } + = f.submit _('Sign in'), class: 'btn btn-success', data: { qa_selector: 'sign_in_button' } diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index 074edf645ba..2cd77af6877 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -5,6 +5,8 @@ = form_for(resource, as: "new_#{resource_name}", url: registration_path(resource_name), html: { class: "new_new_user gl-show-field-errors", "aria-live" => "assertive" }) do |f| .devise-errors = render "devise/shared/error_messages", resource: resource + - if Feature.enabled?(:invisible_captcha) + = invisible_captcha .name.form-group = f.label :name, _('Full name'), class: 'label-bold' = f.text_field :name, class: "form-control top js-block-emoji js-validate-length", :data => { :max_length => max_name_length, :max_length_message => s_("SignUp|Name is too long (maximum is %{max_length} characters).") % { max_length: max_name_length }, :qa_selector => 'new_user_name_field' }, required: true, title: _("This field is required.") diff --git a/app/views/layouts/_snowplow.html.haml b/app/views/layouts/_snowplow.html.haml new file mode 100644 index 00000000000..5f5c5e984c5 --- /dev/null +++ b/app/views/layouts/_snowplow.html.haml @@ -0,0 +1,29 @@ +- return unless Gitlab::CurrentSettings.snowplow_enabled? + += javascript_tag nonce: true do + :plain + ;(function(p,l,o,w,i,n,g){if(!p[i]){p.GlobalSnowplowNamespace=p.GlobalSnowplowNamespace||[]; + p.GlobalSnowplowNamespace.push(i);p[i]=function(){(p[i].q=p[i].q||[]).push(arguments) + };p[i].q=p[i].q||[];n=l.createElement(o);g=l.getElementsByTagName(o)[0];n.async=1; + n.src=w;g.parentNode.insertBefore(n,g)}}(window,document,"script","#{asset_url('snowplow/sp.js')}","snowplow")); + + window.snowplow('newTracker', '#{Gitlab::SnowplowTracker::NAMESPACE}', '#{Gitlab::CurrentSettings.snowplow_collector_hostname}', { + appId: '#{Gitlab::CurrentSettings.snowplow_site_id}', + cookieDomain: '#{Gitlab::CurrentSettings.snowplow_cookie_domain}', + userFingerprint: false, + respectDoNotTrack: true, + forceSecureTracker: true, + post: true, + contexts: { webPage: true }, + stateStorageStrategy: "localStorage" + }); + + window.snowplow('enableActivityTracking', 30, 30); + window.snowplow('trackPageView'); + +- return unless Feature.enabled?(:additional_snowplow_tracking, @group) + += javascript_tag nonce: true do + :plain + window.snowplow('enableFormTracking'); + window.snowplow('enableLinkClickTracking'); diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index d16e2dddbe0..d99063e344f 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -45,20 +45,20 @@ .form-group = f.label :layout, class: 'label-bold' do = s_('Preferences|Layout width') - = f.select :layout, layout_choices, {}, class: 'form-control' + = f.select :layout, layout_choices, {}, class: 'select2' .form-text.text-muted = s_('Preferences|Choose between fixed (max. 1280px) and fluid (100%%) application layout.') .form-group = f.label :dashboard, class: 'label-bold' do = s_('Preferences|Default dashboard') - = f.select :dashboard, dashboard_choices, {}, class: 'form-control' + = f.select :dashboard, dashboard_choices, {}, class: 'select2' = render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific .form-group = f.label :project_view, class: 'label-bold' do = s_('Preferences|Project overview content') - = f.select :project_view, project_view_choices, {}, class: 'form-control' + = f.select :project_view, project_view_choices, {}, class: 'select2' .form-text.text-muted = s_('Preferences|Choose what content you want to see on a project’s overview page.') @@ -82,7 +82,7 @@ .form-group = f.label :first_day_of_week, class: 'label-bold' do = _('First day of the week') - = f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'form-control' + = f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'select2' - if Feature.enabled?(:user_time_settings) .col-sm-12 %hr diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index 280ec6d715b..eb100e5cf47 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -43,7 +43,8 @@ = _('Mirrored repositories') = render_if_exists 'projects/mirrors/mirrored_repositories_count' %th= _('Direction') - %th= _('Last update') + %th= _('Last update attempt') + %th= _('Last successful update') %th %th %tbody.js-mirrors-table-body @@ -53,6 +54,8 @@ %tr.qa-mirrored-repository-row.rspec-mirrored-repository-row{ class: ('bg-secondary' if mirror.disabled?) } %td.qa-mirror-repository-url= mirror.safe_url %td= _('Push') + %td + = mirror.last_update_started_at.present? ? time_ago_with_tooltip(mirror.last_update_started_at) : _('Never') %td.qa-mirror-last-update-at= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never') %td - if mirror.disabled? diff --git a/app/views/projects/project_members/_new_project_member.html.haml b/app/views/projects/project_members/_new_project_member.html.haml index efabb7f7b19..149b0d6cddd 100644 --- a/app/views/projects/project_members/_new_project_member.html.haml +++ b/app/views/projects/project_members/_new_project_member.html.haml @@ -2,7 +2,7 @@ .col-sm-12 = form_for @project_member, as: :project_member, url: project_project_members_path(@project), html: { class: 'users-project-form' } do |f| .form-group - = label_tag :user_ids, _("Select members to invite"), class: "label-bold" + = label_tag :user_ids, _("GitLab member or Email address"), class: "label-bold" = users_select_tag(:user_ids, multiple: true, class: "input-clamp qa-member-select-input", scope: :all, email_user: true, placeholder: "Search for members to update or invite") .form-group = label_tag :access_level, _("Choose a role permission"), class: "label-bold" diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml index 66a614b0197..858731b2dda 100644 --- a/app/views/projects/wikis/_form.html.haml +++ b/app/views/projects/wikis/_form.html.haml @@ -14,7 +14,7 @@ .col-sm-12 = f.text_field :title, class: 'form-control qa-wiki-title-textbox', value: @page.title - if @page.persisted? - %span.edit-wiki-page-slug-tip + %span.d-inline-block.mw-100.prepend-top-5 = icon('lightbulb-o') = s_("WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title.") = link_to icon('question-circle'), help_page_path('user/project/wiki/index', anchor: 'moving-a-wiki-page'), target: '_blank' diff --git a/app/views/projects/wikis/_new.html.haml b/app/views/projects/wikis/_new.html.haml index dc12e368b35..2c675c0de9c 100644 --- a/app/views/projects/wikis/_new.html.haml +++ b/app/views/projects/wikis/_new.html.haml @@ -11,7 +11,7 @@ = label_tag :new_wiki_path do %span= s_("WikiPage|Page slug") = text_field_tag :new_wiki_path, nil, placeholder: s_("WikiNewPagePlaceholder|how-to-setup"), class: 'form-control', required: true, :'data-wikis-path' => project_wikis_path(@project), autofocus: true - %span.new-wiki-page-slug-tip + %span.d-inline-block.mw-100.prepend-top-5 = icon('lightbulb-o') = s_("WikiNewPageTip|Tip: You can specify the full path for the new file. We will automatically create any missing directories.") .form-actions diff --git a/app/views/projects/wikis/edit.html.haml b/app/views/projects/wikis/edit.html.haml index 1277ea6c743..e8b59a3b8c4 100644 --- a/app/views/projects/wikis/edit.html.haml +++ b/app/views/projects/wikis/edit.html.haml @@ -5,7 +5,7 @@ = wiki_page_errors(@error) -.wiki-page-header.has-sidebar-toggle +.wiki-page-header.top-area.has-sidebar-toggle %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" } = icon('angle-double-left') diff --git a/app/views/projects/wikis/git_access.html.haml b/app/views/projects/wikis/git_access.html.haml index 8c2cbd495a0..009133be117 100644 --- a/app/views/projects/wikis/git_access.html.haml +++ b/app/views/projects/wikis/git_access.html.haml @@ -1,7 +1,7 @@ - @content_class = "limit-container-width" unless fluid_layout - page_title s_("WikiClone|Git Access"), _("Wiki") -.wiki-page-header.has-sidebar-toggle +.wiki-page-header.top-area.has-sidebar-toggle %button.btn.btn-default.d-block.d-sm-block.d-md-none.float-right.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" } = icon('angle-double-left') diff --git a/app/views/projects/wikis/history.html.haml b/app/views/projects/wikis/history.html.haml index c5fbeeafa54..f8468ef9a78 100644 --- a/app/views/projects/wikis/history.html.haml +++ b/app/views/projects/wikis/history.html.haml @@ -1,6 +1,6 @@ - page_title _("History"), @page.human_title, _("Wiki") -.wiki-page-header.has-sidebar-toggle +.wiki-page-header.top-area.has-sidebar-toggle %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" } = icon('angle-double-left') diff --git a/app/views/projects/wikis/pages.html.haml b/app/views/projects/wikis/pages.html.haml index 2191e5ab287..f7999c3f1bd 100644 --- a/app/views/projects/wikis/pages.html.haml +++ b/app/views/projects/wikis/pages.html.haml @@ -5,7 +5,7 @@ - sort_title = wiki_sort_title(params[:sort]) %div{ class: container_class } - .wiki-page-header + .wiki-page-header.top-area .nav-text.flex-fill %h2.wiki-page-title diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml index 95cd3356ec8..1d649886331 100644 --- a/app/views/projects/wikis/show.html.haml +++ b/app/views/projects/wikis/show.html.haml @@ -4,7 +4,7 @@ - page_title @page.human_title, _("Wiki") - add_to_breadcrumbs _("Wiki"), project_wiki_path(@project, :home) -.wiki-page-header.has-sidebar-toggle +.wiki-page-header.top-area.has-sidebar-toggle %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" } = icon('angle-double-left') diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index d8dfbc0faf7..61d34981458 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -42,8 +42,8 @@ class PostReceive user = identify_user(post_received) return false unless user - # Expire the branches cache so we have updated data for this push - post_received.project.repository.expire_branches_cache if post_received.includes_branches? + # We only need to expire certain caches once per push + expire_caches(post_received) post_received.enum_for(:changes_refs).with_index do |(oldrev, newrev, ref), index| service_klass = @@ -72,9 +72,34 @@ class PostReceive after_project_changes_hooks(post_received, user, refs.to_a, changes) end + # Expire the project, branch, and tag cache once per push. Schedule an + # update for the repository size and commit count if necessary. + def expire_caches(post_received) + project = post_received.project + + project.repository.expire_status_cache if project.empty_repo? + project.repository.expire_branches_cache if post_received.includes_branches? + project.repository.expire_caches_for_tags if post_received.includes_tags? + + enqueue_repository_cache_update(post_received) + end + + def enqueue_repository_cache_update(post_received) + stats_to_invalidate = [:repository_size] + stats_to_invalidate << :commit_count if post_received.includes_default_branch? + + ProjectCacheWorker.perform_async( + post_received.project.id, + [], + stats_to_invalidate, + true + ) + end + def after_project_changes_hooks(post_received, user, refs, changes) hook_data = Gitlab::DataBuilder::Repository.update(post_received.project, user, changes, refs) SystemHooksService.new.execute_hooks(hook_data, :repository_update_hooks) + Gitlab::UsageDataCounters::SourceCodeCounter.count(:pushes) end def process_wiki_changes(post_received) diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb index 4e8ea903139..5ac860c93e0 100644 --- a/app/workers/project_cache_worker.rb +++ b/app/workers/project_cache_worker.rb @@ -12,13 +12,15 @@ class ProjectCacheWorker # CHANGELOG. # statistics - An Array containing columns from ProjectStatistics to # refresh, if empty all columns will be refreshed + # refresh_statistics - A boolean that determines whether project statistics should + # be updated. # rubocop: disable CodeReuse/ActiveRecord - def perform(project_id, files = [], statistics = []) + def perform(project_id, files = [], statistics = [], refresh_statistics = true) project = Project.find_by(id: project_id) return unless project - update_statistics(project, statistics) + update_statistics(project, statistics) if refresh_statistics return unless project.repository.exists? diff --git a/app/workers/remote_mirror_notification_worker.rb b/app/workers/remote_mirror_notification_worker.rb index 5bafe8e2046..368abfeda99 100644 --- a/app/workers/remote_mirror_notification_worker.rb +++ b/app/workers/remote_mirror_notification_worker.rb @@ -4,7 +4,7 @@ class RemoteMirrorNotificationWorker include ApplicationWorker def perform(remote_mirror_id) - remote_mirror = RemoteMirrorFinder.new(id: remote_mirror_id).execute + remote_mirror = RemoteMirror.find_by_id(remote_mirror_id) # We check again if there's an error because a newer run since this job was # fired could've completed successfully. diff --git a/app/workers/repository_update_remote_mirror_worker.rb b/app/workers/repository_update_remote_mirror_worker.rb index 03a7ff2cd7a..d13c7641eb3 100644 --- a/app/workers/repository_update_remote_mirror_worker.rb +++ b/app/workers/repository_update_remote_mirror_worker.rb @@ -1,50 +1,53 @@ # frozen_string_literal: true class RepositoryUpdateRemoteMirrorWorker - UpdateAlreadyInProgressError = Class.new(StandardError) UpdateError = Class.new(StandardError) include ApplicationWorker + include Gitlab::ExclusiveLeaseHelpers sidekiq_options retry: 3, dead: false - sidekiq_retry_in { |count| 30 * count } + LOCK_WAIT_TIME = 30.seconds + MAX_TRIES = 3 - sidekiq_retries_exhausted do |msg, _| - Sidekiq.logger.warn "Failed #{msg['class']} with #{msg['args']}: #{msg['error_message']}" - end - - def perform(remote_mirror_id, scheduled_time) - remote_mirror = RemoteMirrorFinder.new(id: remote_mirror_id).execute + def perform(remote_mirror_id, scheduled_time, tries = 0) + remote_mirror = RemoteMirror.find_by_id(remote_mirror_id) + return unless remote_mirror return if remote_mirror.updated_since?(scheduled_time) - raise UpdateAlreadyInProgressError if remote_mirror.update_in_progress? + # If the update is already running, wait for it to finish before running again + # This will wait for a total of 90 seconds in 3 steps + in_lock(remote_mirror_update_lock(remote_mirror.id), + retries: 3, + ttl: remote_mirror.max_runtime, + sleep_sec: LOCK_WAIT_TIME) do + update_mirror(remote_mirror, scheduled_time, tries) + end + rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError + # If an update runs longer than 1.5 minutes, we'll reschedule it + # with a backoff. The next run will check if the previous update would + # include the changes that triggered this update and become a no-op. + self.class.perform_in(remote_mirror.backoff_delay, remote_mirror.id, scheduled_time, tries) + end - remote_mirror.update_start + private - project = remote_mirror.project + def update_mirror(mirror, scheduled_time, tries) + project = mirror.project current_user = project.creator - result = Projects::UpdateRemoteMirrorService.new(project, current_user).execute(remote_mirror) - raise UpdateError, result[:message] if result[:status] == :error - - remote_mirror.update_finish - rescue UpdateAlreadyInProgressError - raise - rescue UpdateError => ex - fail_remote_mirror(remote_mirror, ex.message) - raise - rescue => ex - return unless remote_mirror + result = Projects::UpdateRemoteMirrorService.new(project, current_user).execute(mirror, tries) - fail_remote_mirror(remote_mirror, ex.message) - raise UpdateError, "#{ex.class}: #{ex.message}" + if result[:status] == :error && mirror.to_retry? + schedule_retry(mirror, scheduled_time, tries) + end end - private - - def fail_remote_mirror(remote_mirror, message) - remote_mirror.mark_as_failed(message) + def remote_mirror_update_lock(mirror_id) + [self.class.name, mirror_id].join(':') + end - Rails.logger.error(message) # rubocop:disable Gitlab/RailsLogger + def schedule_retry(mirror, scheduled_time, tries) + self.class.perform_in(mirror.backoff_delay, mirror.id, scheduled_time, tries + 1) end end diff --git a/changelogs/unreleased/10-adjust-copy-for-adding-additional-members.yml b/changelogs/unreleased/10-adjust-copy-for-adding-additional-members.yml new file mode 100644 index 00000000000..f249eff572c --- /dev/null +++ b/changelogs/unreleased/10-adjust-copy-for-adding-additional-members.yml @@ -0,0 +1,5 @@ +--- +title: Adjust copy for adding additional members +merge_request: 31726 +author: +type: changed diff --git a/changelogs/unreleased/12502-add-view-stats-to-cycle-analytics.yml b/changelogs/unreleased/12502-add-view-stats-to-cycle-analytics.yml new file mode 100644 index 00000000000..ccfd929b6ba --- /dev/null +++ b/changelogs/unreleased/12502-add-view-stats-to-cycle-analytics.yml @@ -0,0 +1,5 @@ +--- +title: Track page views for cycle analytics show page +merge_request: 31717 +author: +type: added diff --git a/changelogs/unreleased/24705-multi-selection-for-delete-on-registry-page.yml b/changelogs/unreleased/24705-multi-selection-for-delete-on-registry-page.yml new file mode 100644 index 00000000000..5254bd36b9c --- /dev/null +++ b/changelogs/unreleased/24705-multi-selection-for-delete-on-registry-page.yml @@ -0,0 +1,5 @@ +--- +title: Added multi-select deletion of container registry images +merge_request: 30837 +author: +type: other diff --git a/changelogs/unreleased/50020-allow-email-notifications-to-be-disabled-for-all-users-of-a-group.yml b/changelogs/unreleased/50020-allow-email-notifications-to-be-disabled-for-all-users-of-a-group.yml new file mode 100644 index 00000000000..9137e9339aa --- /dev/null +++ b/changelogs/unreleased/50020-allow-email-notifications-to-be-disabled-for-all-users-of-a-group.yml @@ -0,0 +1,5 @@ +--- +title: Allow email notifications to be disabled for all members of a group or project +merge_request: 30755 +author: Dustin Spicuzza +type: added diff --git a/changelogs/unreleased/56130-deployment-date.yml b/changelogs/unreleased/56130-deployment-date.yml new file mode 100644 index 00000000000..7d1e84bbaa4 --- /dev/null +++ b/changelogs/unreleased/56130-deployment-date.yml @@ -0,0 +1,5 @@ +--- +title: Add finished_at to the internal API Deployment entity +merge_request: 31808 +author: +type: other diff --git a/changelogs/unreleased/59829-fix-style-lint-wiki.yml b/changelogs/unreleased/59829-fix-style-lint-wiki.yml new file mode 100644 index 00000000000..48242a77c6b --- /dev/null +++ b/changelogs/unreleased/59829-fix-style-lint-wiki.yml @@ -0,0 +1,5 @@ +--- +title: Fix the style-lint errors and warnings for `app/assets/stylesheets/pages/wiki.scss` +merge_request: 31656 +author: +type: other diff --git a/changelogs/unreleased/61335-fix-file-icon-status.yml b/changelogs/unreleased/61335-fix-file-icon-status.yml new file mode 100644 index 00000000000..d524d91b246 --- /dev/null +++ b/changelogs/unreleased/61335-fix-file-icon-status.yml @@ -0,0 +1,5 @@ +--- +title: Fix IDE new files icon in tree +merge_request: 31560 +author: +type: fixed diff --git a/changelogs/unreleased/62286-Consistent-selection-elements-in-user-settings-preferences.yml b/changelogs/unreleased/62286-Consistent-selection-elements-in-user-settings-preferences.yml new file mode 100644 index 00000000000..10f2b7eaed5 --- /dev/null +++ b/changelogs/unreleased/62286-Consistent-selection-elements-in-user-settings-preferences.yml @@ -0,0 +1,5 @@ +--- +title: Harmonize selections in user settings +merge_request: 31110 +author: Marc Schwede +type: other diff --git a/changelogs/unreleased/64950-move-download-csv-button-functionality-in-metrics-dashboard-cards-i.yml b/changelogs/unreleased/64950-move-download-csv-button-functionality-in-metrics-dashboard-cards-i.yml new file mode 100644 index 00000000000..21771c76873 --- /dev/null +++ b/changelogs/unreleased/64950-move-download-csv-button-functionality-in-metrics-dashboard-cards-i.yml @@ -0,0 +1,5 @@ +--- +title: 'feat: adds a download to csv functionality to the dropdown in prometheus metrics' +merge_request: 31679 +author: +type: changed diff --git a/changelogs/unreleased/65483-add-a-resend-confirmation-link.yml b/changelogs/unreleased/65483-add-a-resend-confirmation-link.yml new file mode 100644 index 00000000000..a5f62dbcd56 --- /dev/null +++ b/changelogs/unreleased/65483-add-a-resend-confirmation-link.yml @@ -0,0 +1,5 @@ +--- +title: Allow users to resend a confirmation link when the grace period has expired +merge_request: 31476 +author: +type: changed diff --git a/changelogs/unreleased/66008-fix-project-image-in-slack-notifications.yml b/changelogs/unreleased/66008-fix-project-image-in-slack-notifications.yml new file mode 100644 index 00000000000..df0ac649ac1 --- /dev/null +++ b/changelogs/unreleased/66008-fix-project-image-in-slack-notifications.yml @@ -0,0 +1,5 @@ +--- +title: Fix project avatar image in Slack pipeline notifications +merge_request: 31788 +author: +type: fixed diff --git a/changelogs/unreleased/bump_helm_kubectl_gitlab.yml b/changelogs/unreleased/bump_helm_kubectl_gitlab.yml new file mode 100644 index 00000000000..d768462e130 --- /dev/null +++ b/changelogs/unreleased/bump_helm_kubectl_gitlab.yml @@ -0,0 +1,5 @@ +--- +title: Bump Helm to 2.14.3 and kubectl to 1.11.10 for Kubernetes integration +merge_request: 31716 +author: +type: other diff --git a/changelogs/unreleased/bvl-remote-mirror-exception-handling.yml b/changelogs/unreleased/bvl-remote-mirror-exception-handling.yml new file mode 100644 index 00000000000..962376086b0 --- /dev/null +++ b/changelogs/unreleased/bvl-remote-mirror-exception-handling.yml @@ -0,0 +1,6 @@ +--- +title: Retry push mirrors faster when running concurrently, improve error handling + when push mirrors fail +merge_request: 31247 +author: +type: changed diff --git a/changelogs/unreleased/dblessing-fix-public-project-ssh-only-ci-failure.yml b/changelogs/unreleased/dblessing-fix-public-project-ssh-only-ci-failure.yml new file mode 100644 index 00000000000..615a1571e95 --- /dev/null +++ b/changelogs/unreleased/dblessing-fix-public-project-ssh-only-ci-failure.yml @@ -0,0 +1,5 @@ +--- +title: Allow CI to clone public projects when HTTP protocol is disabled +merge_request: 31632 +author: +type: fixed diff --git a/changelogs/unreleased/enable-specific-embeds.yml b/changelogs/unreleased/enable-specific-embeds.yml new file mode 100644 index 00000000000..f2e591621a8 --- /dev/null +++ b/changelogs/unreleased/enable-specific-embeds.yml @@ -0,0 +1,5 @@ +--- +title: Enable embedding of specific metrics charts in GFM +merge_request: 31304 +author: +type: added diff --git a/changelogs/unreleased/fix-commits-api-empty-refname.yml b/changelogs/unreleased/fix-commits-api-empty-refname.yml new file mode 100644 index 00000000000..efdb950e45d --- /dev/null +++ b/changelogs/unreleased/fix-commits-api-empty-refname.yml @@ -0,0 +1,5 @@ +--- +title: Fix 500 errors in commits api caused by empty ref_name parameter +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/georgekoltsov-48854-fix-empty-flash-message.yml b/changelogs/unreleased/georgekoltsov-48854-fix-empty-flash-message.yml new file mode 100644 index 00000000000..e28dbd6f0c4 --- /dev/null +++ b/changelogs/unreleased/georgekoltsov-48854-fix-empty-flash-message.yml @@ -0,0 +1,6 @@ +--- +title: Fix empty error flash message on profile:account page when updating username + with username that has already been taken +merge_request: 31809 +author: +type: fixed diff --git a/changelogs/unreleased/id-source-code-smau.yml b/changelogs/unreleased/id-source-code-smau.yml new file mode 100644 index 00000000000..6ba5068544e --- /dev/null +++ b/changelogs/unreleased/id-source-code-smau.yml @@ -0,0 +1,5 @@ +--- +title: Add usage pings for source code pushes +merge_request: 31734 +author: +type: added diff --git a/changelogs/unreleased/new-cycle-analytics-backend-migrations.yml b/changelogs/unreleased/new-cycle-analytics-backend-migrations.yml new file mode 100644 index 00000000000..d56a07fe569 --- /dev/null +++ b/changelogs/unreleased/new-cycle-analytics-backend-migrations.yml @@ -0,0 +1,5 @@ +--- +title: Create database tables for the new cycle analytics backend +merge_request: 31621 +author: +type: other diff --git a/changelogs/unreleased/optimize-note-indexes.yml b/changelogs/unreleased/optimize-note-indexes.yml new file mode 100644 index 00000000000..bfb84779abf --- /dev/null +++ b/changelogs/unreleased/optimize-note-indexes.yml @@ -0,0 +1,5 @@ +--- +title: Optimize DB indexes for ES indexing of notes +merge_request: 31846 +author: +type: performance diff --git a/changelogs/unreleased/post-migrate-private-profile.yml b/changelogs/unreleased/post-migrate-private-profile.yml new file mode 100644 index 00000000000..53a55661aa0 --- /dev/null +++ b/changelogs/unreleased/post-migrate-private-profile.yml @@ -0,0 +1,5 @@ +--- +title: Migrate remaining users with null private_profile +merge_request: 31708 +author: +type: other diff --git a/changelogs/unreleased/rf-remove-group-overview-security-dashboard-feature-flag.yml b/changelogs/unreleased/rf-remove-group-overview-security-dashboard-feature-flag.yml new file mode 100644 index 00000000000..f412ba11b91 --- /dev/null +++ b/changelogs/unreleased/rf-remove-group-overview-security-dashboard-feature-flag.yml @@ -0,0 +1,5 @@ +--- +title: Remove Security Dashboard feature flag +merge_request: 31820 +author: +type: other diff --git a/changelogs/unreleased/security-2873-blocked-user-slash-command-bypass-master.yml b/changelogs/unreleased/security-2873-blocked-user-slash-command-bypass-master.yml new file mode 100644 index 00000000000..cd31fe0f35c --- /dev/null +++ b/changelogs/unreleased/security-2873-blocked-user-slash-command-bypass-master.yml @@ -0,0 +1,5 @@ +--- +title: Restrict slash commands to users who can log in +merge_request: +author: +type: security diff --git a/changelogs/unreleased/sh-fix-discussions-api-perf.yml b/changelogs/unreleased/sh-fix-discussions-api-perf.yml new file mode 100644 index 00000000000..8cdbbf03dab --- /dev/null +++ b/changelogs/unreleased/sh-fix-discussions-api-perf.yml @@ -0,0 +1,5 @@ +--- +title: Eliminate many Gitaly calls in discussions API +merge_request: 31834 +author: +type: performance diff --git a/changelogs/unreleased/sh-only-flush-tags-once-per-push.yml b/changelogs/unreleased/sh-only-flush-tags-once-per-push.yml new file mode 100644 index 00000000000..502fc22ebbd --- /dev/null +++ b/changelogs/unreleased/sh-only-flush-tags-once-per-push.yml @@ -0,0 +1,5 @@ +--- +title: Only expire tag cache once per push +merge_request: 31641 +author: +type: performance diff --git a/changelogs/unreleased/sh-optimize-commit-deltas-post-receive.yml b/changelogs/unreleased/sh-optimize-commit-deltas-post-receive.yml new file mode 100644 index 00000000000..cd63b9bf425 --- /dev/null +++ b/changelogs/unreleased/sh-optimize-commit-deltas-post-receive.yml @@ -0,0 +1,5 @@ +--- +title: Reduce Gitaly calls in PostReceive +merge_request: 31741 +author: +type: performance diff --git a/changelogs/unreleased/sh-post-receive-cache-clear-once.yml b/changelogs/unreleased/sh-post-receive-cache-clear-once.yml new file mode 100644 index 00000000000..b677adf78d9 --- /dev/null +++ b/changelogs/unreleased/sh-post-receive-cache-clear-once.yml @@ -0,0 +1,5 @@ +--- +title: Expire project caches once per push instead of once per ref +merge_request: 31876 +author: +type: performance diff --git a/changelogs/unreleased/sh-update-rugged-0-28-3.yml b/changelogs/unreleased/sh-update-rugged-0-28-3.yml new file mode 100644 index 00000000000..86446564e12 --- /dev/null +++ b/changelogs/unreleased/sh-update-rugged-0-28-3.yml @@ -0,0 +1,5 @@ +--- +title: Upgrade Rugged to 0.28.3 +merge_request: 31794 +author: +type: security diff --git a/changelogs/unreleased/tr-embed-metric-links.yml b/changelogs/unreleased/tr-embed-metric-links.yml new file mode 100644 index 00000000000..6918114a4ae --- /dev/null +++ b/changelogs/unreleased/tr-embed-metric-links.yml @@ -0,0 +1,5 @@ +--- +title: Generate shareable link for specific metric charts +merge_request: 31339 +author: +type: added diff --git a/config/initializers/8_devise.rb b/config/initializers/8_devise.rb index 3dd12c7e64d..8ef9ff6b7fc 100644 --- a/config/initializers/8_devise.rb +++ b/config/initializers/8_devise.rb @@ -81,7 +81,7 @@ Devise.setup do |config| # You can use this to let your user access some features of your application # without confirming the account, but blocking it after a certain period # (ie 2 days). - # config.allow_unconfirmed_access_for = 2.days + config.allow_unconfirmed_access_for = 30.days # Defines which key will be used when confirming an account # config.confirmation_keys = [ :email ] diff --git a/config/initializers/elastic_client_setup.rb b/config/initializers/elastic_client_setup.rb index 2ecb7956007..a1abb29838b 100644 --- a/config/initializers/elastic_client_setup.rb +++ b/config/initializers/elastic_client_setup.rb @@ -5,46 +5,42 @@ require 'gitlab/current_settings' Gitlab.ee do + require 'elasticsearch/model' + + ### Modified from elasticsearch-model/lib/elasticsearch/model.rb + + [ + Elasticsearch::Model::Client::ClassMethods, + Elasticsearch::Model::Naming::ClassMethods, + Elasticsearch::Model::Indexing::ClassMethods, + Elasticsearch::Model::Searching::ClassMethods + ].each do |mod| + Elasticsearch::Model::Proxy::ClassMethodsProxy.include mod + end + + [ + Elasticsearch::Model::Client::InstanceMethods, + Elasticsearch::Model::Naming::InstanceMethods, + Elasticsearch::Model::Indexing::InstanceMethods, + Elasticsearch::Model::Serializing::InstanceMethods + ].each do |mod| + Elasticsearch::Model::Proxy::InstanceMethodsProxy.include mod + end + + Elasticsearch::Model::Proxy::InstanceMethodsProxy.class_eval <<-CODE, __FILE__, __LINE__ + 1 + def as_indexed_json(options={}) + target.respond_to?(:as_indexed_json) ? target.__send__(:as_indexed_json, options) : super + end + CODE + + ### Monkey patches + Elasticsearch::Model::Response::Records.prepend GemExtensions::Elasticsearch::Model::Response::Records Elasticsearch::Model::Adapter::Multiple::Records.prepend GemExtensions::Elasticsearch::Model::Adapter::Multiple::Records Elasticsearch::Model::Indexing::InstanceMethods.prepend GemExtensions::Elasticsearch::Model::Indexing::InstanceMethods - - module Elasticsearch - module Model - module Client - # This mutex is only used to synchronize *creation* of a new client, so - # all including classes can share the same client instance - CLIENT_MUTEX = Mutex.new - - cattr_accessor :cached_client - cattr_accessor :cached_config - - module ClassMethods - # Override the default ::Elasticsearch::Model::Client implementation to - # return a client configured from application settings. All including - # classes will use the same instance, which is refreshed automatically - # if the settings change. - # - # _client is present to match the arity of the overridden method, where - # it is also not used. - # - # @return [Elasticsearch::Transport::Client] - def client(_client = nil) - store = ::Elasticsearch::Model::Client - - store::CLIENT_MUTEX.synchronize do - config = Gitlab::CurrentSettings.elasticsearch_config - - if store.cached_client.nil? || config != store.cached_config - store.cached_client = ::Gitlab::Elastic::Client.build(config) - store.cached_config = config - end - end - - store.cached_client - end - end - end - end - end + Elasticsearch::Model::Adapter::ActiveRecord::Importing.prepend GemExtensions::Elasticsearch::Model::Adapter::ActiveRecord::Importing + Elasticsearch::Model::Client::InstanceMethods.prepend GemExtensions::Elasticsearch::Model::Client + Elasticsearch::Model::Client::ClassMethods.prepend GemExtensions::Elasticsearch::Model::Client + Elasticsearch::Model::ClassMethods.prepend GemExtensions::Elasticsearch::Model::Client + Elasticsearch::Model.singleton_class.prepend GemExtensions::Elasticsearch::Model::Client end diff --git a/config/initializers/invisible_captcha.rb b/config/initializers/invisible_captcha.rb new file mode 100644 index 00000000000..5177c730596 --- /dev/null +++ b/config/initializers/invisible_captcha.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +InvisibleCaptcha.setup do |config| + config.honeypots = %w(firstname lastname) + config.timestamp_enabled = true + config.timestamp_threshold = 4 +end diff --git a/config/locales/invisible_captcha.en.yml b/config/locales/invisible_captcha.en.yml new file mode 100644 index 00000000000..5978549c0c3 --- /dev/null +++ b/config/locales/invisible_captcha.en.yml @@ -0,0 +1,4 @@ +en: + invisible_captcha: + sentence_for_humans: If you are human, please ignore this field. + timestamp_error_message: That was a bit too quick! Please resubmit. diff --git a/config/routes/project.rb b/config/routes/project.rb index b9258a35f0c..9a453d101a1 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -477,7 +477,11 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do # in JSON format, or a request for tag named `latest.json`. scope format: false do resources :tags, only: [:index, :destroy], - constraints: { id: Gitlab::Regex.container_registry_tag_regex } + constraints: { id: Gitlab::Regex.container_registry_tag_regex } do + collection do + delete :bulk_destroy + end + end end end end @@ -505,7 +509,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do get :discussions, format: :json Gitlab.ee do - get 'designs(/*vueroute)', to: 'issues#show', format: false + get 'designs(/*vueroute)', to: 'issues#show', as: :designs, format: false end end diff --git a/config/routes/user.rb b/config/routes/user.rb index 3f768d5d384..d4616c8080d 100644 --- a/config/routes/user.rb +++ b/config/routes/user.rb @@ -43,13 +43,6 @@ scope '-/users', module: :users do end end -scope '-/users', module: :users do - resources :terms, only: [:index] do - post :accept, on: :member - post :decline, on: :member - end -end - scope(constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }) do scope(path: 'users/:username', as: :user, diff --git a/db/migrate/20190715173819_add_object_storage_flag_to_geo_node.rb b/db/migrate/20190715173819_add_object_storage_flag_to_geo_node.rb new file mode 100644 index 00000000000..2d3243f3357 --- /dev/null +++ b/db/migrate/20190715173819_add_object_storage_flag_to_geo_node.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddObjectStorageFlagToGeoNode < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default :geo_nodes, :sync_object_storage, :boolean, default: false + end + + def down + remove_column :geo_nodes, :sync_object_storage + end +end diff --git a/db/migrate/20190715215532_add_project_emails_disabled.rb b/db/migrate/20190715215532_add_project_emails_disabled.rb new file mode 100644 index 00000000000..536ea34c0fb --- /dev/null +++ b/db/migrate/20190715215532_add_project_emails_disabled.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddProjectEmailsDisabled < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + add_column :projects, :emails_disabled, :boolean + end +end diff --git a/db/migrate/20190715215549_add_group_emails_disabled.rb b/db/migrate/20190715215549_add_group_emails_disabled.rb new file mode 100644 index 00000000000..d3fd4d2d923 --- /dev/null +++ b/db/migrate/20190715215549_add_group_emails_disabled.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddGroupEmailsDisabled < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + add_column :namespaces, :emails_disabled, :boolean + end +end diff --git a/db/migrate/20190716144222_create_analytics_cycle_analytics_project_stages.rb b/db/migrate/20190716144222_create_analytics_cycle_analytics_project_stages.rb new file mode 100644 index 00000000000..5c005377b00 --- /dev/null +++ b/db/migrate/20190716144222_create_analytics_cycle_analytics_project_stages.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class CreateAnalyticsCycleAnalyticsProjectStages < ActiveRecord::Migration[5.2] + DOWNTIME = false + + INDEX_PREFIX = 'index_analytics_ca_project_stages_' + + def change + create_table :analytics_cycle_analytics_project_stages do |t| + t.timestamps_with_timezone + t.integer :relative_position + t.integer :start_event_identifier, null: false + t.integer :end_event_identifier, null: false + t.references(:project, { + null: false, + foreign_key: { to_table: :projects, on_delete: :cascade }, + index: { name: INDEX_PREFIX + 'on_project_id' } + }) + t.references(:start_event_label, { + foreign_key: { to_table: :labels, on_delete: :cascade }, + index: { name: INDEX_PREFIX + 'on_start_event_label_id' } + }) + t.references(:end_event_label, { + foreign_key: { to_table: :labels, on_delete: :cascade }, + index: { name: INDEX_PREFIX + 'on_end_event_label_id' } + }) + t.boolean :hidden, default: false, null: false + t.boolean :custom, default: true, null: false + t.string :name, null: false, limit: 255 + end + + add_index :analytics_cycle_analytics_project_stages, [:project_id, :name], unique: true, name: INDEX_PREFIX + 'on_project_id_and_name' + add_index :analytics_cycle_analytics_project_stages, [:relative_position], name: INDEX_PREFIX + 'on_relative_position' + end +end diff --git a/db/migrate/20190729062536_create_analytics_cycle_analytics_group_stages.rb b/db/migrate/20190729062536_create_analytics_cycle_analytics_group_stages.rb new file mode 100644 index 00000000000..5b327dc5332 --- /dev/null +++ b/db/migrate/20190729062536_create_analytics_cycle_analytics_group_stages.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class CreateAnalyticsCycleAnalyticsGroupStages < ActiveRecord::Migration[5.2] + DOWNTIME = false + + INDEX_PREFIX = 'index_analytics_ca_group_stages_' + + def change + create_table :analytics_cycle_analytics_group_stages do |t| + t.timestamps_with_timezone + t.integer :relative_position + t.integer :start_event_identifier, null: false + t.integer :end_event_identifier, null: false + t.references(:group, { + null: false, + foreign_key: { to_table: :namespaces, on_delete: :cascade }, + index: { name: INDEX_PREFIX + 'on_group_id' } + }) + t.references(:start_event_label, { + foreign_key: { to_table: :labels, on_delete: :cascade }, + index: { name: INDEX_PREFIX + 'on_start_event_label_id' } + }) + t.references(:end_event_label, { + foreign_key: { to_table: :labels, on_delete: :cascade }, + index: { name: INDEX_PREFIX + 'on_end_event_label_id' } + }) + t.boolean :hidden, default: false, null: false + t.boolean :custom, default: true, null: false + t.string :name, null: false, limit: 255 + end + + add_index :analytics_cycle_analytics_group_stages, [:group_id, :name], unique: true, name: INDEX_PREFIX + 'on_group_id_and_name' + add_index :analytics_cycle_analytics_group_stages, [:relative_position], name: INDEX_PREFIX + 'on_relative_position' + end +end diff --git a/db/migrate/20190815093936_add_index_notes_on_project_id_and_id_and_system_false.rb b/db/migrate/20190815093936_add_index_notes_on_project_id_and_id_and_system_false.rb new file mode 100644 index 00000000000..cbbece35901 --- /dev/null +++ b/db/migrate/20190815093936_add_index_notes_on_project_id_and_id_and_system_false.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class AddIndexNotesOnProjectIdAndIdAndSystemFalse < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index(*index_arguments) + end + + def down + remove_concurrent_index(*index_arguments) + end + + private + + def index_arguments + [ + :notes, + [:project_id, :id], + { + name: 'index_notes_on_project_id_and_id_and_system_false', + where: 'NOT system' + } + ] + end +end diff --git a/db/migrate/20190815093949_remove_index_notes_on_noteable_type.rb b/db/migrate/20190815093949_remove_index_notes_on_noteable_type.rb new file mode 100644 index 00000000000..158c88e6258 --- /dev/null +++ b/db/migrate/20190815093949_remove_index_notes_on_noteable_type.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class RemoveIndexNotesOnNoteableType < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + remove_concurrent_index(*index_arguments) + end + + def down + add_concurrent_index(*index_arguments) + end + + private + + def index_arguments + [ + :notes, + [:noteable_type], + { + name: 'index_notes_on_noteable_type' + } + ] + end +end diff --git a/db/post_migrate/20190812070645_migrate_private_profile_nulls.rb b/db/post_migrate/20190812070645_migrate_private_profile_nulls.rb new file mode 100644 index 00000000000..063c1e16c27 --- /dev/null +++ b/db/post_migrate/20190812070645_migrate_private_profile_nulls.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class MigratePrivateProfileNulls < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + DELAY = 5.minutes.to_i + BATCH_SIZE = 1_000 + + disable_ddl_transaction! + + class User < ActiveRecord::Base + self.table_name = 'users' + + include ::EachBatch + end + + def up + # Migration will take about 7 hours + User.where(private_profile: nil).each_batch(of: BATCH_SIZE) do |batch, index| + range = batch.pluck(Arel.sql("MIN(id)"), Arel.sql("MAX(id)")).first + delay = index * DELAY + + BackgroundMigrationWorker.perform_in(delay.seconds, 'MigrateNullPrivateProfileToFalse', [*range]) + end + end + + def down + # noop + end +end diff --git a/db/schema.rb b/db/schema.rb index 591758af0e4..fafaec3fb51 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2019_08_06_071559) do +ActiveRecord::Schema.define(version: 2019_08_15_093949) do # These are extensions that must be enabled in order to support this database enable_extension "pg_trgm" @@ -26,6 +26,44 @@ ActiveRecord::Schema.define(version: 2019_08_06_071559) do t.integer "cached_markdown_version" end + create_table "analytics_cycle_analytics_group_stages", force: :cascade do |t| + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.integer "relative_position" + t.integer "start_event_identifier", null: false + t.integer "end_event_identifier", null: false + t.bigint "group_id", null: false + t.bigint "start_event_label_id" + t.bigint "end_event_label_id" + t.boolean "hidden", default: false, null: false + t.boolean "custom", default: true, null: false + t.string "name", limit: 255, null: false + t.index ["end_event_label_id"], name: "index_analytics_ca_group_stages_on_end_event_label_id" + t.index ["group_id", "name"], name: "index_analytics_ca_group_stages_on_group_id_and_name", unique: true + t.index ["group_id"], name: "index_analytics_ca_group_stages_on_group_id" + t.index ["relative_position"], name: "index_analytics_ca_group_stages_on_relative_position" + t.index ["start_event_label_id"], name: "index_analytics_ca_group_stages_on_start_event_label_id" + end + + create_table "analytics_cycle_analytics_project_stages", force: :cascade do |t| + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.integer "relative_position" + t.integer "start_event_identifier", null: false + t.integer "end_event_identifier", null: false + t.bigint "project_id", null: false + t.bigint "start_event_label_id" + t.bigint "end_event_label_id" + t.boolean "hidden", default: false, null: false + t.boolean "custom", default: true, null: false + t.string "name", limit: 255, null: false + t.index ["end_event_label_id"], name: "index_analytics_ca_project_stages_on_end_event_label_id" + t.index ["project_id", "name"], name: "index_analytics_ca_project_stages_on_project_id_and_name", unique: true + t.index ["project_id"], name: "index_analytics_ca_project_stages_on_project_id" + t.index ["relative_position"], name: "index_analytics_ca_project_stages_on_relative_position" + t.index ["start_event_label_id"], name: "index_analytics_ca_project_stages_on_start_event_label_id" + end + create_table "appearances", id: :serial, force: :cascade do |t| t.string "title", null: false t.text "description", null: false @@ -1456,6 +1494,7 @@ ActiveRecord::Schema.define(version: 2019_08_06_071559) do t.integer "container_repositories_max_capacity", default: 10, null: false t.datetime_with_timezone "created_at" t.datetime_with_timezone "updated_at" + t.boolean "sync_object_storage", default: false, null: false t.index ["access_key"], name: "index_geo_nodes_on_access_key" t.index ["name"], name: "index_geo_nodes_on_name", unique: true t.index ["primary"], name: "index_geo_nodes_on_primary" @@ -2174,6 +2213,7 @@ ActiveRecord::Schema.define(version: 2019_08_06_071559) do t.boolean "membership_lock", default: false t.integer "last_ci_minutes_usage_notification_level" t.integer "subgroup_creation_level", default: 1 + t.boolean "emails_disabled" t.index ["created_at"], name: "index_namespaces_on_created_at" t.index ["custom_project_templates_group_id", "type"], name: "index_namespaces_on_custom_project_templates_group_id_and_type", where: "(custom_project_templates_group_id IS NOT NULL)" t.index ["file_template_project_id"], name: "index_namespaces_on_file_template_project_id" @@ -2239,7 +2279,7 @@ ActiveRecord::Schema.define(version: 2019_08_06_071559) do t.index ["line_code"], name: "index_notes_on_line_code" t.index ["note"], name: "index_notes_on_note_trigram", opclass: :gin_trgm_ops, using: :gin t.index ["noteable_id", "noteable_type"], name: "index_notes_on_noteable_id_and_noteable_type" - t.index ["noteable_type"], name: "index_notes_on_noteable_type" + t.index ["project_id", "id"], name: "index_notes_on_project_id_and_id_and_system_false", where: "(NOT system)" t.index ["project_id", "noteable_type"], name: "index_notes_on_project_id_and_noteable_type" t.index ["review_id"], name: "index_notes_on_review_id" end @@ -2744,6 +2784,7 @@ ActiveRecord::Schema.define(version: 2019_08_06_071559) do t.boolean "reset_approvals_on_push", default: true t.boolean "service_desk_enabled", default: true t.integer "approvals_before_merge", default: 0, null: false + t.boolean "emails_disabled" t.index ["archived", "pending_delete", "merge_requests_require_code_owner_approval"], name: "projects_requiring_code_owner_approval", where: "((pending_delete = false) AND (archived = false) AND (merge_requests_require_code_owner_approval = true))" t.index ["created_at"], name: "index_projects_on_created_at" t.index ["creator_id"], name: "index_projects_on_creator_id" @@ -3629,6 +3670,12 @@ ActiveRecord::Schema.define(version: 2019_08_06_071559) do t.index ["type"], name: "index_web_hooks_on_type" end + add_foreign_key "analytics_cycle_analytics_group_stages", "labels", column: "end_event_label_id", on_delete: :cascade + add_foreign_key "analytics_cycle_analytics_group_stages", "labels", column: "start_event_label_id", on_delete: :cascade + add_foreign_key "analytics_cycle_analytics_group_stages", "namespaces", column: "group_id", on_delete: :cascade + add_foreign_key "analytics_cycle_analytics_project_stages", "labels", column: "end_event_label_id", on_delete: :cascade + add_foreign_key "analytics_cycle_analytics_project_stages", "labels", column: "start_event_label_id", on_delete: :cascade + add_foreign_key "analytics_cycle_analytics_project_stages", "projects", on_delete: :cascade add_foreign_key "application_settings", "namespaces", column: "custom_project_templates_group_id", on_delete: :nullify add_foreign_key "application_settings", "projects", column: "file_template_project_id", name: "fk_ec757bd087", on_delete: :nullify add_foreign_key "application_settings", "projects", column: "instance_administration_project_id", on_delete: :nullify diff --git a/doc/README.md b/doc/README.md index c60e4eb177d..8ce5d2e240a 100644 --- a/doc/README.md +++ b/doc/README.md @@ -354,6 +354,7 @@ The following documentation relates to the DevOps **Secure** stage: | Secure Topics | Description | |:------------------------------------------------------------------------------------------------------|:-----------------------------------------------------------------------| | [Container Scanning](user/application_security/container_scanning/index.md) **(ULTIMATE)** | Use Clair to scan docker images for known vulnerabilities. | +| [Dependency List](user/application_security/dependency_list/index.md) **(ULTIMATE)** | View your project's dependencies and their known vulnerabilities. | | [Dependency Scanning](user/application_security/dependency_scanning/index.md) **(ULTIMATE)** | Analyze your dependencies for known vulnerabilities. | | [Dynamic Application Security Testing (DAST)](user/application_security/dast/index.md) **(ULTIMATE)** | Analyze running web applications for known vulnerabilities. | | [Group Security Dashboard](user/application_security/security_dashboard/index.md) **(ULTIMATE)** | View vulnerabilities in all the projects in a group and its subgroups. | diff --git a/doc/administration/container_registry.md b/doc/administration/container_registry.md index e418938451a..d0adeb89543 100644 --- a/doc/administration/container_registry.md +++ b/doc/administration/container_registry.md @@ -669,6 +669,39 @@ To get around this, you can [change the group path](../user/group/index.md#chang branch name. Another option is to create a [push rule](../push_rules/push_rules.html) to prevent this at the instance level. +### Image push errors + +When getting errors or "retrying" loops in an attempt to push an image but `docker login` works fine, +there is likely an issue with the headers forwarded to the registry by NGINX. The default recommended +NGINX configurations should handle this, but it might occur in custom setups where the SSL is +offloaded to a third party reverse proxy. + +This problem was discussed in a [docker project issue][docker-image-push-issue] and a simple solution +would be to enable relative urls in the registry. + +**For Omnibus installations** + +1. Edit `/etc/gitlab/gitlab.rb`: + + ```ruby + registry['env'] = { + "REGISTRY_HTTP_RELATIVEURLS" => true + } + ``` + +1. Save the file and [reconfigure GitLab][] for the changes to take effect. + +**For installations from source** + +1. Edit the YML configuration file you created when you [deployed the registry][registry-deploy]. Add the following snippet: + + ```yaml + http: + relativeurls: true + ``` + +1. Restart the registry for the changes to take affect. + [ce-18239]: https://gitlab.com/gitlab-org/gitlab-ce/issues/18239 [docker-insecure-self-signed]: https://docs.docker.com/registry/insecure/#use-self-signed-certificates [reconfigure gitlab]: restart_gitlab.md#omnibus-gitlab-reconfigure @@ -687,3 +720,4 @@ this at the instance level. [new-domain]: #configure-container-registry-under-its-own-domain [notifications-config]: https://docs.docker.com/registry/notifications/ [registry-notifications-config]: https://docs.docker.com/registry/configuration/#notifications +[docker-image-push-issue]: https://github.com/docker/distribution/issues/970 diff --git a/doc/administration/integration/plantuml.md b/doc/administration/integration/plantuml.md index c2ac063ce37..16a193550a1 100644 --- a/doc/administration/integration/plantuml.md +++ b/doc/administration/integration/plantuml.md @@ -72,12 +72,12 @@ our AsciiDoc snippets, wikis and repos using delimited blocks: - **Markdown** - ````markdown + ~~~markdown ```plantuml Bob -> Alice : hello Alice -> Bob : Go Away ``` - ```` + ~~~ - **AsciiDoc** diff --git a/doc/administration/monitoring/prometheus/gitlab_metrics.md b/doc/administration/monitoring/prometheus/gitlab_metrics.md index 054fa547704..ec26c0b2e7e 100644 --- a/doc/administration/monitoring/prometheus/gitlab_metrics.md +++ b/doc/administration/monitoring/prometheus/gitlab_metrics.md @@ -120,7 +120,6 @@ When Puma is used instead of Unicorn, following metrics are available: | puma_workers | Gauge | 12.0 | Total number of workers | | puma_running_workers | Gauge | 12.0 | Number of booted workers | | puma_stale_workers | Gauge | 12.0 | Number of old workers | -| puma_phase | Gauge | 12.0 | Phase number (increased during phased restarts) | | puma_running | Gauge | 12.0 | Number of running threads | | puma_queued_connections | Gauge | 12.0 | Number of connections in that worker's "todo" set waiting for a worker thread | | puma_active_connections | Gauge | 12.0 | Number of threads processing a request | diff --git a/doc/api/dependencies.md b/doc/api/dependencies.md index 2496b038c7f..015ffbe60f6 100644 --- a/doc/api/dependencies.md +++ b/doc/api/dependencies.md @@ -11,7 +11,7 @@ Every call to this endpoint requires authentication. To perform this call, user ## List project dependencies Get a list of project dependencies. This API partially mirroring -[Dependency List](../user/application_security/dependency_scanning/index.md#dependency-list) feature. +[Dependency List](../user/application_security/dependency_list/index.md) feature. This list can be generated only for [languages and package managers](../user/application_security/dependency_scanning/index.md#supported-languages-and-package-managers) supported by Gemnasium. diff --git a/doc/api/geo_nodes.md b/doc/api/geo_nodes.md index 1c702b59a08..d0b33ab467f 100644 --- a/doc/api/geo_nodes.md +++ b/doc/api/geo_nodes.md @@ -10,7 +10,7 @@ GET /geo_nodes ``` ```bash -curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/geo_nodes +curl --header "PRIVATE-TOKEN: <your_access_token>" https://primary.example.com/api/v4/geo_nodes ``` Example response: @@ -29,7 +29,13 @@ Example response: "repos_max_capacity": 25, "container_repositories_max_capacity": 10, "verification_max_capacity": 100, - "clone_protocol": "http" + "clone_protocol": "http", + "web_edit_url": "https://primary.example.com/admin/geo/nodes/1/edit", + "_links": { + "self": "https://primary.example.com/api/v4/geo_nodes/1", + "status":"https://primary.example.com/api/v4/geo_nodes/1/status", + "repair":"https://primary.example.com/api/v4/geo_nodes/1/repair" + } }, { "id": 2, @@ -43,7 +49,15 @@ Example response: "repos_max_capacity": 25, "container_repositories_max_capacity": 10, "verification_max_capacity": 100, - "clone_protocol": "http" + "sync_object_storage": true, + "clone_protocol": "http", + "web_edit_url": "https://primary.example.com/admin/geo/nodes/2/edit", + "web_geo_projects_url": "https://secondary.example.com/admin/geo/projects", + "_links": { + "self":"https://primary.example.com/api/v4/geo_nodes/2", + "status":"https://primary.example.com/api/v4/geo_nodes/2/status", + "repair":"https://primary.example.com/api/v4/geo_nodes/2/repair" + } } ] ``` @@ -55,7 +69,7 @@ GET /geo_nodes/:id ``` ```bash -curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/geo_nodes/1 +curl --header "PRIVATE-TOKEN: <your_access_token>" https://primary.example.com/api/v4/geo_nodes/1 ``` Example response: @@ -73,7 +87,13 @@ Example response: "repos_max_capacity": 25, "container_repositories_max_capacity": 10, "verification_max_capacity": 100, - "clone_protocol": "http" + "clone_protocol": "http", + "web_edit_url": "https://primary.example.com/admin/geo/nodes/1/edit", + "_links": { + "self": "https://primary.example.com/api/v4/geo_nodes/1", + "status":"https://primary.example.com/api/v4/geo_nodes/1/status", + "repair":"https://primary.example.com/api/v4/geo_nodes/1/repair" + } } ``` @@ -87,17 +107,18 @@ _This can only be run against a primary Geo node._ PUT /geo_nodes/:id ``` -| Attribute | Type | Required | Description | -|----------------------|---------|-----------|---------------------------------------------------------------------------| -| `id` | integer | yes | The ID of the Geo node. | -| `enabled` | boolean | no | Flag indicating if the Geo node is enabled. | -| `name` | string | yes | The unique identifier for the Geo node. Must match `geo_node_name` if it is set in gitlab.rb, otherwise it must match `external_url`. | -| `url` | string | yes | The user-facing URL of the Geo node. | -| `internal_url` | string | no | The URL defined on the primary node that secondary nodes should use to contact it. Returns `url` if not set.| -| `files_max_capacity` | integer | no | Control the maximum concurrency of LFS/attachment backfill for this secondary node. | -| `repos_max_capacity` | integer | no | Control the maximum concurrency of repository backfill for this secondary node. | -| `verification_max_capacity` | integer | no | Control the maximum concurrency of verification for this node. | +| Attribute | Type | Required | Description | +|-----------------------------|---------|-----------|---------------------------------------------------------------------------| +| `id` | integer | yes | The ID of the Geo node. | +| `enabled` | boolean | no | Flag indicating if the Geo node is enabled. | +| `name` | string | yes | The unique identifier for the Geo node. Must match `geo_node_name` if it is set in gitlab.rb, otherwise it must match `external_url`. | +| `url` | string | yes | The user-facing URL of the Geo node. | +| `internal_url` | string | no | The URL defined on the primary node that secondary nodes should use to contact it. Returns `url` if not set.| +| `files_max_capacity` | integer | no | Control the maximum concurrency of LFS/attachment backfill for this secondary node. | +| `repos_max_capacity` | integer | no | Control the maximum concurrency of repository backfill for this secondary node. | +| `verification_max_capacity` | integer | no | Control the maximum concurrency of verification for this node. | | `container_repositories_max_capacity` | integer | no | Control the maximum concurrency of container repository sync for this node. | +| `sync_object_storage` | boolean | no | Flag indicating if the secondary Geo node will replicate blobs in Object Storage. | Example response: @@ -114,7 +135,15 @@ Example response: "repos_max_capacity": 25, "container_repositories_max_capacity": 10, "verification_max_capacity": 100, - "clone_protocol": "http" + "sync_object_storage": true, + "clone_protocol": "http", + "web_edit_url": "https://primary.example.com/admin/geo/nodes/2/edit", + "web_geo_projects_url": "https://secondary.example.com/admin/geo/projects", + "_links": { + "self":"https://primary.example.com/api/v4/geo_nodes/2", + "status":"https://primary.example.com/api/v4/geo_nodes/2/status", + "repair":"https://primary.example.com/api/v4/geo_nodes/2/repair" + } } ``` @@ -158,7 +187,13 @@ Example response: "repos_max_capacity": 25, "container_repositories_max_capacity": 10, "verification_max_capacity": 100, - "clone_protocol": "http" + "clone_protocol": "http", + "web_edit_url": "https://primary.example.com/admin/geo/nodes/1/edit", + "_links": { + "self": "https://primary.example.com/api/v4/geo_nodes/1", + "status":"https://primary.example.com/api/v4/geo_nodes/1/status", + "repair":"https://primary.example.com/api/v4/geo_nodes/1/repair" + } } ``` @@ -169,7 +204,7 @@ GET /geo_nodes/status ``` ```bash -curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/geo_nodes/status +curl --header "PRIVATE-TOKEN: <your_access_token>" https://primary.example.com/api/v4/geo_nodes/status ``` Example response: @@ -320,7 +355,7 @@ GET /geo_nodes/:id/status ``` ```bash -curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/geo_nodes/2/status +curl --header "PRIVATE-TOKEN: <your_access_token>" https://primary.example.com/api/v4/geo_nodes/2/status ``` Example response: @@ -394,7 +429,7 @@ GET /geo_nodes/current/failures This endpoint uses [Pagination](README.md#pagination). ```bash -curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/geo_nodes/current/failures +curl --header "PRIVATE-TOKEN: <your_access_token>" https://primary.example.com/api/v4/geo_nodes/current/failures ``` Example response: diff --git a/doc/api/settings.md b/doc/api/settings.md index 83125aff264..248d19461f6 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -321,4 +321,8 @@ are listed in the descriptions of the relevant settings. | `user_show_add_ssh_key_message` | boolean | no | When set to `false` disable the "You won't be able to pull or push project code via SSH" warning shown to users with no uploaded SSH key. | | `version_check_enabled` | boolean | no | Let GitLab inform you when an update is available. | | `local_markdown_version` | integer | no | Increase this value when any cached markdown should be invalidated. | +| `snowplow_enabled` | boolean | no | Enable snowplow tracking. | +| `snowplow_collector_hostname` | string | required by: `snowplow_enabled` | The Snowplow collector hostname. (e.g. `snowplow.trx.gitlab.net`) | +| `snowplow_site_id` | string | no | The Snowplow site name / application id. (e.g. `gitlab`) | +| `snowplow_cookie_domain` | string | no | The Snowplow cookie domain. (e.g. `.gitlab.com`) | | `geo_node_allowed_ips` | string | yes | **(PREMIUM)** Comma-separated list of IPs and CIDRs of allowed secondary nodes. For example, `1.1.1.1, 2.2.2.0/24`. | diff --git a/doc/ci/directed_acyclic_graph/index.md b/doc/ci/directed_acyclic_graph/index.md new file mode 100644 index 00000000000..e54be9f3bd9 --- /dev/null +++ b/doc/ci/directed_acyclic_graph/index.md @@ -0,0 +1,76 @@ +--- +type: reference +--- + +# Directed Acyclic Graph + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/47063) in GitLab 12.2 (enabled by `ci_dag_support` feature flag). + +A [directed acyclic graph](https://www.techopedia.com/definition/5739/directed-acyclic-graph-dag) can be +used in the context of a CI/CD pipeline to build relationships between jobs such that +execution is performed in the quickest possible manner, regardless how stages may +be set up. + +For example, you may have a specific tool or separate website that is built +as part of your main project. Using a DAG, you can specify the relationship between +these jobs and GitLab will then execute the jobs as soon as possible instead of waiting +for each stage to complete. + +Unlike other DAG solutions for CI/CD, GitLab does not require you to choose one or the +other. You can implement a hybrid combination of DAG and traditional +stage-based operation within a single pipeline. Configuration is kept very simple, +requiring a single keyword to enable the feature for any job. + +Consider a monorepo as follows: + +``` +./service_a +./service_b +./service_c +./service_d +``` + +It has a pipeline that looks like the following: + +| build | test | deploy | +| ----- | ---- | ------ | +| build_a | test_a | deploy_a | +| build_b | test_b | deploy_b | +| build_c | test_c | deploy_c | +| build_d | test_d | deploy_d | + +Using a DAG, you can relate the `_a` jobs to each other separately from the `_b` jobs, +and even if service `a` takes a very long time to build, service `b` will not +wait for it and will finish as quickly as it can. In this very same pipeline, `_c` and +`_d` can be left alone and will run together in staged sequence just like any normal +GitLab pipeline. + +## Use cases + +A DAG can help solve several different kinds of relationships between jobs within +a CI/CD pipeline. Most typically this would cover when jobs need to fan in or out, +and/or merge back together (diamond dependencies). This can happen when you're +handling multi-platform builds or complex webs of dependencies as in something like +an operating system build or a complex deployment graph of independently deployable +but related microservices. + +Additionally, a DAG can help with general speediness of pipelines and helping +to deliver fast feedback. By creating dependency relationships that don't unnecessarily +block each other, your pipelines will run as quickly as possible regardless of +pipeline stages, ensuring output (including errors) is available to developers +as quickly as possible. + +## Usage + +Relationships are defined between jobs using the [`needs:` keyword](../yaml/README.md#needs). + +Note that `needs:` also works with the [parallel](../yaml/README.md#parallel) keyword, +giving your powerful options for parallelization within your pipeline. + +## Limitations + +A directed acyclic graph is a complicated feature, and as of the initial MVC there +are certain use cases that you may need to work around. For more information: + +- [`needs` requirements and limitations](../yaml/README.md#requirements-and-limitations). +- Related epic [gitlab-org#1716](https://gitlab.com/groups/gitlab-org/-/epics/1716). diff --git a/doc/ci/environments.md b/doc/ci/environments.md index 9e1a62bae71..f6c47a99712 100644 --- a/doc/ci/environments.md +++ b/doc/ci/environments.md @@ -619,6 +619,10 @@ versions of the app, all without leaving GitLab. Add a [button to the Monitoring dashboard](../user/project/operations/linking_to_an_external_dashboard.md) linking directly to your existing external dashboards. +#### Embedding metrics in GitLab Flavored Markdown + +Metric charts can be embedded within GitLab Flavored Markdown. See [Embedding Metrics within GitLab Flavored Markdown](../user/project/integrations/prometheus.md#embedding-metric-charts-within-gitlab-flavored-markdown) for more details. + ### Web terminals > Web terminals were added in GitLab 8.15 and are only available to project Maintainers and Owners. diff --git a/doc/ci/multi_project_pipelines.md b/doc/ci/multi_project_pipelines.md index ced4344a0b0..cb8d383f7d9 100644 --- a/doc/ci/multi_project_pipelines.md +++ b/doc/ci/multi_project_pipelines.md @@ -176,6 +176,21 @@ Upstream pipelines take precedence over downstream ones. If there are two variables with the same name defined in both upstream and downstream projects, the ones defined in the upstream project will take precedence. +### Mirroring status from upstream pipeline + +You can mirror the pipeline status from an upstream pipeline to a bridge job by +using the `needs:pipeline` keyword. The latest pipeline status from master is +replicated to the bridge job. + +Example: + +```yaml +upstream_bridge: + stage: test + needs: + pipeline: other/project +``` + ### Limitations Because bridge jobs are a little different to regular jobs, it is not diff --git a/doc/ci/runners/README.md b/doc/ci/runners/README.md index c3678fc948e..8474d4ef66e 100644 --- a/doc/ci/runners/README.md +++ b/doc/ci/runners/README.md @@ -319,21 +319,21 @@ How this feature will work: 1. You set the _maximum job timeout_ for a Runner to 24 hours 1. You set the _CI/CD Timeout_ for a project to **2 hours** 1. You start a job -1. The job, if running longer, will be timeouted after **2 hours** +1. The job, if running longer, will be timed out after **2 hours** **Example 2 - Runner timeout not configured** 1. You remove the _maximum job timeout_ configuration from a Runner 1. You set the _CI/CD Timeout_ for a project to **2 hours** 1. You start a job -1. The job, if running longer, will be timeouted after **2 hours** +1. The job, if running longer, will be timed out after **2 hours** **Example 3 - Runner timeout smaller than project timeout** 1. You set the _maximum job timeout_ for a Runner to **30 minutes** 1. You set the _CI/CD Timeout_ for a project to 2 hours 1. You start a job -1. The job, if running longer, will be timeouted after **30 minutes** +1. The job, if running longer, will be timed out after **30 minutes** ### Be careful with sensitive information diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index a6051e87366..2be93433b36 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -1665,6 +1665,84 @@ You can ask your administrator to [flip this switch](../../administration/job_artifacts.md#validation-for-dependencies) and bring back the old behavior. +### `needs` + +> Introduced in GitLab 12.2. + +The `needs:` keyword enables executing jobs out-of-order, allowing you to implement +a [directed acyclic graph](../directed_acyclic_graph/index.md) in your `.gitlab-ci.yml`. + +This lets you run some jobs without waiting for other ones, disregarding stage ordering +so you can have multiple stages running concurrently. + +Let's consider the following example: + +```yaml +linux:build: + stage: build + +mac:build: + stage: build + +linux:rspec: + stage: test + needs: [linux:build] + +linux:rubocop: + stage: test + needs: [linux:build] + +mac:rspec: + stage: test + needs: [mac:build] + +mac:rubocop: + stage: test + needs: [mac:build] + +production: + stage: deploy +``` + +This example creates three paths of execution: + +- Linux path: the `linux:rspec` and `linux:rubocop` jobs will be run as soon + as the `linux:build` job finishes without waiting for `mac:build` to finish. + +- macOS path: the `mac:rspec` and `mac:rubocop` jobs will be run as soon + as the `mac:build` job finishes, without waiting for `linux:build` to finish. + +- The `production` job will be executed as soon as all previous jobs + finish; in this case: `linux:build`, `linux:rspec`, `linux:rubocop`, + `mac:build`, `mac:rspec`, `mac:rubocop`. + +#### Requirements and limitations + +1. If `needs:` is set to point to a job that is not instantiated + because of `only/except` rules or otherwise does not exist, the + job will fail. +1. Note that one day one of the launch, we are temporarily limiting the + maximum number of jobs that a single job can need in the `needs:` array. Track + our [infrastructure issue](https://gitlab.com/gitlab-com/gl-infra/infrastructure/issues/7541) + for details on the current limit. +1. If you use `dependencies:` with `needs:`, it's important that you + do not mark a job as having a dependency on something that won't + have been run at the time it needs it. It's better to use both + keywords in this case so that GitLab handles the ordering appropriately. +1. It is impossible for now to have `needs: []` (empty needs), + the job always needs to depend on something, unless this is the job + in the first stage (see [gitlab-ce#65504](https://gitlab.com/gitlab-org/gitlab-ce/issues/65504)). +1. If `needs:` refers to a job that is marked as `parallel:`. + the current job will depend on all parallel jobs created. +1. `needs:` is similar to `dependencies:` in that needs to use jobs from + prior stages, this means that it is impossible to create circular + dependencies or depend on jobs in the current stage (see [gitlab-ce#65505](https://gitlab.com/gitlab-org/gitlab-ce/issues/65505)). +1. Related to the above, stages must be explicitly defined for all jobs + that have the keyword `needs:` or are referred to by one. +1. For self-managed users, the feature must be turned on using the `ci_dag_support` + feature flag. The `ci_dag_limit_needs` option, if set, will limit the number of + jobs that a single job can need to `50`. If unset, the limit is `5`. + ### `coverage` > [Introduced][ce-7447] in GitLab 8.17. diff --git a/doc/customization/issue_and_merge_request_template.md b/doc/customization/issue_and_merge_request_template.md index adaa120a37e..ebf711e105b 100644 --- a/doc/customization/issue_and_merge_request_template.md +++ b/doc/customization/issue_and_merge_request_template.md @@ -1,5 +1,5 @@ --- -redirect_to: '../user/project/description_templates.md#setting-a-default-template-for-issues-and-merge-requests--starter' +redirect_to: '../user/project/description_templates.md#setting-a-default-template-for-merge-requests-and-issues--starter' --- -This document was moved to [description_templates](../user/project/description_templates.md#setting-a-default-template-for-issues-and-merge-requests--starter). +This document was moved to [description_templates](../user/project/description_templates.md#setting-a-default-template-for-merge-requests-and-issues--starter). diff --git a/doc/development/automatic_ce_ee_merge.md b/doc/development/automatic_ce_ee_merge.md index 423b35a9e3a..98b8a48abf4 100644 --- a/doc/development/automatic_ce_ee_merge.md +++ b/doc/development/automatic_ce_ee_merge.md @@ -171,6 +171,19 @@ Now, every time you create an MR for CE and EE: job failed, you are required to submit the EE MR so that you can fix the conflicts in EE before merging your changes into CE. +## How we run the Automatic CE->EE merge at GitLab + +At GitLab, we use the [Merge Train](https://gitlab.com/gitlab-org/merge-train) +project to keep our [gitlab-ee](https://gitlab.com/gitlab-org/gitlab-ee) +repository updated with commits from +[gitlab-ce](https://gitlab.com/gitlab-org/gitlab-ce). + +We have a mirror of the [Merge Train](https://gitlab.com/gitlab-org/merge-train) +project [configured](https://ops.gitlab.net/gitlab-org/merge-train) to run an +automatic CE->EE merge job every twenty minutes as a scheduled CI job. The +[configured](https://ops.gitlab.net/gitlab-org/merge-train) Merge Train project +is only accessible to authorized GitLab staff. + ## FAQ ### How does automatic merging work? diff --git a/doc/development/documentation/styleguide.md b/doc/development/documentation/styleguide.md index 59c8bfe2964..c1e3eb9680b 100644 --- a/doc/development/documentation/styleguide.md +++ b/doc/development/documentation/styleguide.md @@ -655,15 +655,16 @@ nicely on different mobile devices. ## Code blocks -- Always wrap code added to a sentence in inline code blocks (``` ` ```). +- Always wrap code added to a sentence in inline code blocks (`` ` ``). E.g., `.gitlab-ci.yml`, `git add .`, `CODEOWNERS`, `only: master`. File names, commands, entries, and anything that refers to code should be added to code blocks. To make things easier for the user, always add a full code block for things that can be useful to copy and paste, as they can easily do it with the button on code blocks. +- Add a blank line above and below code blocks. - For regular code blocks, always use a highlighting class corresponding to the language for better readability. Examples: - ````md + ~~~md ```ruby Ruby code ``` @@ -673,16 +674,17 @@ nicely on different mobile devices. ``` ```md - Markdown code + [Markdown code example](example.md) ``` ```text - Code for which no specific highlighting class is available. + Code or text for which no specific highlighting class is available. ``` - ```` + ~~~ -- To display raw markdown instead of rendered markdown, use four backticks on their own lines around the - markdown to display. See [example](https://gitlab.com/gitlab-org/gitlab-ce/blob/8c1991b9bb7e3b8d606481fdea316d633cfa5eb7/doc/development/documentation/styleguide.md#L275-287). +- To display raw markdown instead of rendered markdown, you can use triple backticks + with `md`, like the `Markdown code` example above, unless you want to include triple + backticks in the code block as well. In that case, use triple tildes (`~~~`) instead. - For a complete reference on code blocks, check the [Kramdown guide](https://about.gitlab.com/handbook/product/technical-writing/markdown-guide/#code-blocks). ## Alert boxes @@ -877,10 +879,10 @@ Other text includes deprecation notices and version-specific how-to information. When a feature is available in EE-only tiers, add the corresponding tier according to the feature availability: +- For GitLab Core and GitLab.com Free: `**(CORE)**`. - For GitLab Starter and GitLab.com Bronze: `**(STARTER)**`. - For GitLab Premium and GitLab.com Silver: `**(PREMIUM)**`. - For GitLab Ultimate and GitLab.com Gold: `**(ULTIMATE)**`. -- For GitLab Core and GitLab.com Free: `**(CORE)**`. To exclude GitLab.com tiers (when the feature is not available in GitLab.com), add the keyword "only": @@ -892,6 +894,7 @@ keyword "only": For GitLab.com only tiers (when the feature is not available for self-hosted instances): +- For GitLab Free and higher tiers: `**(FREE ONLY)**`. - For GitLab Bronze and higher tiers: `**(BRONZE ONLY)**`. - For GitLab Silver and higher tiers: `**(SILVER ONLY)**`. - For GitLab Gold: `**(GOLD ONLY)**`. @@ -1023,7 +1026,7 @@ on this document. Further explanation is given below. The following can be used as a template to get started: -````md +~~~md ## Descriptive title One or two sentence description of what endpoint does. @@ -1051,7 +1054,7 @@ Example response: } ] ``` -```` +~~~ ### Fake tokens @@ -1079,7 +1082,7 @@ You can use the following fake tokens as examples. ### Method description Use the following table headers to describe the methods. Attributes should -always be in code blocks using backticks (``` ` ```). +always be in code blocks using backticks (`` ` ``). ```md | Attribute | Type | Required | Description | diff --git a/doc/development/elasticsearch.md b/doc/development/elasticsearch.md index 0965db29557..635895051bc 100644 --- a/doc/development/elasticsearch.md +++ b/doc/development/elasticsearch.md @@ -148,6 +148,36 @@ Uses an [Edge NGram token filter](https://www.elastic.co/guide/en/elasticsearch/ - Searches can have their own analyzers. Remember to check when editing analyzers - `Character` filters (as opposed to token filters) always replace the original character, so they're not a good choice as they can hinder exact searches +## Architecture + +GitLab uses `elasticsearch-rails` for handling communication with Elasticsearch server. However, in order to achieve zero-downtime deployment during schema changes, an extra abstraction layer is built to allow: + +* Indexing (writes) to multiple indexes, with different mappings +* Switching to different index for searches (reads) on the fly + +Currently we are on the process of migrating models to this new design (e.g. `Snippet`), and it is hardwired to work with a single version for now. + +Traditionally, `elasticsearch-rails` provides class and instance level `__elasticsearch__` proxy methods. If you call `Issue.__elasticsearch__`, you will get an instance of `Elasticsearch::Model::Proxy::ClassMethodsProxy`, and if you call `Issue.first.__elasticsearch__`, you will get an instance of `Elasticsearch::Model::Proxy::InstanceMethodsProxy`. These proxy objects would talk to Elasticsearch server directly. + +In the new design, `__elasticsearch__` instead represents one extra layer of proxy. It would keep multiple versions of the actual proxy objects, and it would forward read and write calls to the proxy of the intended version. + +The `elasticsearch-rails`'s way of specifying each model's mappings and other settings is to create a module for the model to include. However in the new design, each model would have its own corresponding subclassed proxy object, where the settings reside in. For example, snippet related setting in the past reside in `SnippetsSearch` module, but in the new design would reside in `SnippetClassProxy` (which is a subclass of `Elasticsearch::Model::Proxy::ClassMethodsProxy`). This reduces namespace pollution in model classes. + +The global configurations per version are now in the `Elastic::(Version)::Config` class. You can change mappings there. + +### Creating new version of schema + +Currently GitLab would still work with a single version of setting. Once it is implemented, multiple versions of setting can exists in different folders (e.g. `ee/lib/elastic/v12p1` and `ee/lib/elastic/v12p3`). To keep a continuous git history, the latest version lives under the `/latest` folder, but is aliased as the latest version. + +If the current version is `v12p1`, and we need to create a new version for `v12p3`, the steps are as follows: + +1. Copy the entire folder of `v12p1` as `v12p3` +1. Change the namespace for files under `v12p3` folder from `V12p1` to `V12p3` (which are still aliased to `Latest`) +1. Delete `v12p1` folder +1. Copy the entire folder of `latest` as `v12p1` +1. Change the namespace for files under `v12p1` folder from `Latest` to `V12p1` +1. Make changes to `Latest` as needed + ## Troubleshooting ### Getting `flood stage disk watermark [95%] exceeded` diff --git a/doc/development/go_guide/index.md b/doc/development/go_guide/index.md index 9f0ac8cc753..83444093f9c 100644 --- a/doc/development/go_guide/index.md +++ b/doc/development/go_guide/index.md @@ -107,6 +107,32 @@ Modules](https://github.com/golang/go/wiki/Modules). It provides a way to define and lock dependencies for reproducible builds. It should be used whenever possible. +When Go Modules are in use, there should not be a `vendor/` directory. Instead, +Go will automatically download dependencies when they are needed to build the +project. This is in line with how dependencies are handled with Bundler in Ruby +projects, and makes merge requests easier to review. + +In some cases, such as building a Go project for it to act as a dependency of a +CI run for another project, removing the `vendor/` directory means the code must +be downloaded repeatedly, which can lead to intermittent problems due to rate +limiting or network failures. In these circumstances, you should cache the +downloaded code between runs with a `.gitlab-ci.yml` snippet like this: + +```yaml +.go-cache: + variables: + GOPATH: $CI_PROJECT_DIR/.go + before_script: + - mkdir -p .go + cache: + paths: + - .go/pkg/mod/ + +test: + extends: .go-cache + # ... +``` + There was a [bug on modules checksums](https://github.com/golang/go/issues/29278) in Go < v1.11.4, so make sure to use at least this version to avoid `checksum mismatch` errors. diff --git a/doc/development/testing_guide/best_practices.md b/doc/development/testing_guide/best_practices.md index 448d9fd01c4..9d6792e9139 100644 --- a/doc/development/testing_guide/best_practices.md +++ b/doc/development/testing_guide/best_practices.md @@ -70,6 +70,7 @@ bundle exec rspec spec/[path]/[to]/[spec].rb - On `before` and `after` hooks, prefer it scoped to `:context` over `:all` - When using `evaluate_script("$('.js-foo').testSomething()")` (or `execute_script`) which acts on a given element, use a Capyabara matcher beforehand (e.g. `find('.js-foo')`) to ensure the element actually exists. +- Use `focus: true` to isolate parts of the specs you want to run. [four-phase-test]: https://robots.thoughtbot.com/four-phase-test diff --git a/doc/install/installation.md b/doc/install/installation.md index 72a3514e2d5..295d9804497 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -172,7 +172,7 @@ sudo make install # Download and compile from source cd /tmp curl --remote-name --location --progress https://www.kernel.org/pub/software/scm/git/git-2.22.0.tar.gz -echo 'a4b7e4365bee43caa12a38d646d2c93743d755d1cea5eab448ffb40906c9da0b' git-2.22.0.tar.gz' | shasum -a256 -c - && tar -xzf git-2.22.0.tar.gz +echo 'a4b7e4365bee43caa12a38d646d2c93743d755d1cea5eab448ffb40906c9da0b git-2.22.0.tar.gz' | shasum -a256 -c - && tar -xzf git-2.22.0.tar.gz cd git-2.22.0/ ./configure --with-libpcre make prefix=/usr/local all @@ -202,8 +202,8 @@ Then select 'Internet Site' and press enter to confirm the hostname. The Ruby interpreter is required to run GitLab. -**Note:** The current supported Ruby (MRI) version is 2.5.x. GitLab 11.6 - dropped support for Ruby 2.4.x. +**Note:** The current supported Ruby (MRI) version is 2.6.x. GitLab 12.2 + dropped support for Ruby 2.5.x. The use of Ruby version managers such as [RVM], [rbenv] or [chruby] with GitLab in production, frequently leads to hard to diagnose problems. For example, diff --git a/doc/install/requirements.md b/doc/install/requirements.md index 83a9e7fe294..234e5acb394 100644 --- a/doc/install/requirements.md +++ b/doc/install/requirements.md @@ -40,7 +40,7 @@ Please consider using a virtual machine to run GitLab. ## Ruby versions -GitLab requires Ruby (MRI) 2.5. Support for Ruby versions below 2.5 (2.3, 2.4) will stop with GitLab 11.6. +GitLab requires Ruby (MRI) 2.6. Support for Ruby versions below 2.6 (2.4, 2.5) will stop with GitLab 12.2. You will have to use the standard MRI implementation of Ruby. We love [JRuby](https://www.jruby.org/) and [Rubinius](https://rubinius.com) but GitLab diff --git a/doc/integration/elasticsearch.md b/doc/integration/elasticsearch.md index 1c80fc543af..eee05eaef02 100644 --- a/doc/integration/elasticsearch.md +++ b/doc/integration/elasticsearch.md @@ -333,6 +333,10 @@ curl --request PUT localhost:9200/gitlab-production/_settings --data '{ Enable Elasticsearch search in **Admin > Settings > Integrations**. That's it. Enjoy it! +### Index limit + +Currently for repository and snippet files, GitLab would only index up to 1 MB of content, in order to avoid indexing timeout. + ## GitLab Elasticsearch Rake Tasks There are several rake tasks available to you via the command line: diff --git a/doc/security/rate_limits.md b/doc/security/rate_limits.md index 0e5bdcd9c79..c80f2f264b2 100644 --- a/doc/security/rate_limits.md +++ b/doc/security/rate_limits.md @@ -22,11 +22,12 @@ similarly mitigated by a rate limit. ## Admin Area settings -See -[User and IP rate limits](../user/admin_area/settings/user_and_ip_rate_limits.md). +- [User and IP rate limits](../user/admin_area/settings/user_and_ip_rate_limits.md). +- [Rate limits on raw endpoints](../user/admin_area/settings/rate_limits_on_raw_endpoints.md) ## Rack Attack initializer This method of rate limiting is cumbersome, but has some advantages. It allows throttling of specific paths, and is also integrated into Git and container registry requests. See [Rack Attack initializer](rack_attack.md). + diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md index 95220d6364c..9c1258fa1aa 100644 --- a/doc/topics/autodevops/index.md +++ b/doc/topics/autodevops/index.md @@ -816,7 +816,7 @@ To configure your application variables: 1. Create a CI Variable, ensuring the key is prefixed with `K8S_SECRET_`. For example, you can create a variable with key -`K8S_SECRET_RAILS_MASTER_KEY`. + `K8S_SECRET_RAILS_MASTER_KEY`. 1. Run an Auto Devops pipeline either by manually creating a new pipeline or by pushing a code change to GitLab. @@ -1017,10 +1017,10 @@ Everything behaves the same way, except: - It's enabled by setting the `INCREMENTAL_ROLLOUT_MODE` variable to `timed`. - Instead of the standard `production` job, the following jobs with a 5 minute delay between each are created: - 1. `timed rollout 10%` - 1. `timed rollout 25%` - 1. `timed rollout 50%` - 1. `timed rollout 100%` + 1. `timed rollout 10%` + 1. `timed rollout 25%` + 1. `timed rollout 50%` + 1. `timed rollout 100%` ## Currently supported languages diff --git a/doc/university/training/end-user/README.md b/doc/university/training/end-user/README.md index 423ba1cfbd7..1218465c87a 100644 --- a/doc/university/training/end-user/README.md +++ b/doc/university/training/end-user/README.md @@ -9,12 +9,8 @@ which can be found at [End User Slides](https://gitlab-org.gitlab.io/end-user-tr through it's [RevealJS](https://gitlab.com/gitlab-org/end-user-training-slides) project. ---- - ## Git Intro ---- - ### What is a Version Control System (VCS) - Records changes to a file @@ -22,8 +18,6 @@ project. - Disaster Recovery - Types of VCS: Local, Centralized and Distributed ---- - ### Short Story of Git - 1991-2002: The Linux kernel was being maintained by sharing archived files @@ -31,8 +25,6 @@ project. - 2002: The Linux kernel project began using a DVCS called BitKeeper - 2005: BitKeeper revoked the free-of-charge status and Git was created ---- - ### What is Git - Distributed Version Control System @@ -42,8 +34,6 @@ project. - Disaster recovery friendly - Open Source ---- - ### Getting Help - Use the tools at your disposal when you get stuck. @@ -51,14 +41,10 @@ project. - Use Google (i.e. StackOverflow, Google groups) - Read documentation at <https://git-scm.com> ---- - ## Git Setup Workshop Time! ---- - ### Setup - Windows: Install 'Git for Windows' @@ -69,8 +55,6 @@ Workshop Time! - Debian: `sudo apt-get install git-all` - Red Hat `sudo yum install git-all` ---- - ### Configure - One-time configuration of the Git client: @@ -91,16 +75,12 @@ git config --global --list - You might want or be required to use an SSH key. - Instructions: [SSH](http://doc.gitlab.com/ce/ssh/README.html) ---- - ### Workspace - Choose a directory on you machine easy to access - Create a workspace or development directory - This is where we'll be working and adding content ---- - ```bash mkdir ~/development cd ~/development @@ -111,12 +91,8 @@ mkdir ~/workspace cd ~/workspace ``` ---- - ## Git Basics ---- - ### Git Workflow - Untracked files @@ -128,8 +104,6 @@ cd ~/workspace - Upstream - Hosted repository on a shared server ---- - ### GitLab - GitLab is an application to code, test and deploy. @@ -137,8 +111,6 @@ cd ~/workspace issue tracking, Merge Requests, and other features. - The hosted version of GitLab is gitlab.com ---- - ### New Project - Sign in into your gitlab.com account @@ -146,8 +118,6 @@ cd ~/workspace - Choose to import from 'Any Repo by URL' and use <https://gitlab.com/gitlab-org/training-examples.git> - On your machine clone the `training-examples` project ---- - ### Git and GitLab basics 1. Edit `edit_this_file.rb` in `training-examples` @@ -158,8 +128,6 @@ cd ~/workspace 1. Push the commit to the remote 1. View the git log ---- - ```shell # Edit `edit_this_file.rb` git status @@ -170,8 +138,6 @@ git push origin master git log ``` ---- - ### Feature Branching 1. Create a new feature branch called `squash_some_bugs` @@ -179,8 +145,6 @@ git log 1. Commit 1. Push ---- - ```shell git checkout -b squash_some_bugs # Edit `bugs.rb` @@ -190,14 +154,8 @@ git commit -m 'Fix some buggy code' git push origin squash_some_bugs ``` ---- - ## Merge Request ---- - -### Merge requests - - When you want feedback create a merge request - Target is the ‘default’ branch (usually master) - Assign or mention the person you would like to review @@ -206,8 +164,6 @@ git push origin squash_some_bugs - Anyone can comment, not just the assignee - Push corrections to the same branch ---- - ### Merge request example - Create your first merge request @@ -216,8 +172,6 @@ git push origin squash_some_bugs - Push a new commit to the same branch - Review the changes again and notice the update ---- - ### Feedback and Collaboration - Merge requests are a time for feedback and collaboration @@ -230,24 +184,17 @@ git push origin squash_some_bugs --- -- Review the Thoughtbot code-review guide for suggestions to follow when reviewing merge requests:[Thoughtbot](https://github.com/thoughtbot/guides/tree/master/code-review) +- Review the Thoughtbot code-review guide for suggestions to follow when reviewing merge requests: + [Thoughtbot](https://github.com/thoughtbot/guides/tree/master/code-review) - See GitLab merge requests for examples: [Merge Requests](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests) ---- - ## Merge Conflicts ---- - -### Merge Conflicts - - Happen often - Learning to fix conflicts is hard - Practice makes perfect - Force push after fixing conflicts. Be careful! ---- - ### Example Plan 1. Checkout a new branch and edit conflicts.rb. Add 'Line4' and 'Line5'. @@ -261,8 +208,6 @@ git push origin squash_some_bugs 1. Force push the changes 1. Finally continue with the Merge Request ---- - ### Example 1/2 ```sh @@ -282,8 +227,6 @@ git commit -am "add line6 and line7" git push origin master ``` ---- - ### Example 2/2 Create a merge request on the GitLab web UI. You'll see a conflict warning. @@ -305,8 +248,6 @@ git rebase --continue git push origin conflicts_branch -f ``` ---- - ### Notes - When to use `git merge` and when to use `git rebase` @@ -314,12 +255,8 @@ git push origin conflicts_branch -f - Merge when bringing changes from feature to master - Reference: <https://www.atlassian.com/git/tutorials/merging-vs-rebasing/> ---- - ## Revert and Unstage ---- - ### Unstage To remove files from stage use reset HEAD. Where HEAD is the last commit of the current branch: @@ -347,8 +284,6 @@ If we want to remove a file from the repository but keep it on disk, say we forg git rm <filename> --cache ``` ---- - ### Undo Commits Undo last commit putting everything back into the staging area: @@ -377,8 +312,6 @@ git reset --hard HEAD^^ Don't reset after pushing ---- - ### Reset Workflow 1. Edit file again 'edit_this_file.rb' @@ -392,8 +325,6 @@ Don't reset after pushing 1. Pull for updates 1. Push changes ---- - ```sh # Change file edit_this_file.rb git status @@ -407,8 +338,6 @@ git pull origin master git push origin master ``` ---- - ### git revert vs git reset Reset removes the commit while revert removes the changes but leaves the commit @@ -425,16 +354,10 @@ git revert <rev commit hash> # reverted commit is back (new commit created again) ``` ---- - ## Questions ---- - ## Instructor Notes ---- - ### Version Control - Local VCS was used with a filesystem or a simple db. diff --git a/doc/university/training/topics/bisect.md b/doc/university/training/topics/bisect.md index 4178afa2086..24dc670d9d5 100644 --- a/doc/university/training/topics/bisect.md +++ b/doc/university/training/topics/bisect.md @@ -4,13 +4,11 @@ comments: false # Bisect -## Bisect - - Find a commit that introduced a bug - Works through a process of elimination - Specify a known good and bad revision to begin -## Bisect +## Bisect sample workflow 1. Start the bisect process 1. Enter the bad revision (usually latest commit) diff --git a/doc/university/training/topics/cherry_picking.md b/doc/university/training/topics/cherry_picking.md index fa0cb5fe6a4..f5bcdfcbf12 100644 --- a/doc/university/training/topics/cherry_picking.md +++ b/doc/university/training/topics/cherry_picking.md @@ -4,13 +4,11 @@ comments: false # Cherry Pick -## Cherry Pick - - Given an existing commit on one branch, apply the change to another branch - Useful for backporting bug fixes to previous release branches - Make the commit on the master branch and pick in to stable -## Cherry Pick +## Cherry Pick sample workflow 1. Check out a new 'stable' branch from 'master' 1. Change back to 'master' @@ -19,8 +17,6 @@ comments: false 1. Check out the 'stable' branch 1. Cherry pick the commit using the SHA obtained earlier -## Commands - ```bash git checkout master git checkout -b stable diff --git a/doc/university/training/topics/feature_branching.md b/doc/university/training/topics/feature_branching.md index d2efe634533..f530389d4da 100644 --- a/doc/university/training/topics/feature_branching.md +++ b/doc/university/training/topics/feature_branching.md @@ -11,15 +11,13 @@ comments: false - Push branches to the server frequently - Hint: This is a cheap backup for your work-in-progress code -## Feature branching +## Feature branching sample workflow 1. Create a new feature branch called 'squash_some_bugs' 1. Edit '`bugs.rb`' and remove all the bugs. 1. Commit 1. Push -## Commands - ```sh git checkout -b squash_some_bugs # Edit `bugs.rb` diff --git a/doc/university/training/topics/getting_started.md b/doc/university/training/topics/getting_started.md index e8ff7916590..3fadb58e804 100644 --- a/doc/university/training/topics/getting_started.md +++ b/doc/university/training/topics/getting_started.md @@ -35,8 +35,6 @@ comments: false 1. Create a '`Workspace`' directory in your home directory. 1. Clone the '`training-examples`' project. -## Commands - ```sh mkdir ~/workspace cd ~/workspace @@ -69,8 +67,6 @@ Modified files that have been marked to go in the next commit. 1. Push the commit to the remote 1. View the git log -## Commands - ```sh # Edit `edit_this_file.rb` git status diff --git a/doc/university/training/topics/git_add.md b/doc/university/training/topics/git_add.md index 7152fc2030b..0c9a50bb5e1 100644 --- a/doc/university/training/topics/git_add.md +++ b/doc/university/training/topics/git_add.md @@ -4,8 +4,6 @@ comments: false # Git Add -## Git Add - Adds content to the index or staging area. - Adds a list of file: @@ -20,8 +18,6 @@ Adds content to the index or staging area. git add -A ``` -## Git add continued - - Add all text files in current dir: ```bash diff --git a/doc/university/training/topics/merge_conflicts.md b/doc/university/training/topics/merge_conflicts.md index dd235fe3a81..97bb038f405 100644 --- a/doc/university/training/topics/merge_conflicts.md +++ b/doc/university/training/topics/merge_conflicts.md @@ -9,7 +9,7 @@ comments: false - Practice makes perfect - Force push after fixing conflicts. Be careful! -## Merge conflicts +## Merge conflicts sample workflow 1. Checkout a new branch and edit `conflicts.rb`. Add 'Line4' and 'Line5'. 1. Commit and push. @@ -22,8 +22,6 @@ comments: false 1. Force push the changes. 1. Finally continue with the Merge Request. -## Commands - ```sh git checkout -b conflicts_branch diff --git a/doc/university/training/topics/merge_requests.md b/doc/university/training/topics/merge_requests.md index b5bbe7b2e1e..656871ae5b2 100644 --- a/doc/university/training/topics/merge_requests.md +++ b/doc/university/training/topics/merge_requests.md @@ -30,8 +30,6 @@ comments: false - Be as receptive as possible - Feedback is about the best code, not the person. You are not your code -## Feedback and Collaboration - Review the Thoughtbot code-review guide for suggestions to follow when reviewing merge requests: [https://github.com/thoughtbot/guides/tree/master/code-review](https://github.com/thoughtbot/guides/tree/master/code-review) diff --git a/doc/university/training/topics/stash.md b/doc/university/training/topics/stash.md index 21abad88cfa..d3e63db0c6a 100644 --- a/doc/university/training/topics/stash.md +++ b/doc/university/training/topics/stash.md @@ -25,7 +25,7 @@ and we need to change to a different branch. git stash apply stash@{3} ``` -- Every time we save a stash it gets stacked so by using list we can see all our +- Every time we save a stash it gets stacked so by using `list` we can see all our stashes. ```sh @@ -54,7 +54,7 @@ and we need to change to a different branch. - If we meet conflicts we need to either reset or commit our changes. - Conflicts through `pop` will not drop a stash afterwards. -## Git Stash +## Git Stash sample workflow 1. Modify a file 1. Stage file @@ -64,8 +64,6 @@ and we need to change to a different branch. 1. Apply with pop 1. View list to confirm changes -## Commands - ```sh # Modify edit_this_file.rb file git add . diff --git a/doc/university/training/topics/tags.md b/doc/university/training/topics/tags.md index cdbb8a2da7c..631b93cc384 100644 --- a/doc/university/training/topics/tags.md +++ b/doc/university/training/topics/tags.md @@ -11,18 +11,12 @@ type: reference - Many projects combine an annotated release tag with a stable branch - Consider setting deployment/release tags automatically -# Tags +## Tags sample workflow - Create a lightweight tag - Create an annotated tag - Push the tags to the remote repository -**Additional resources** - -<https://git-scm.com/book/en/Git-Basics-Tagging> - -# Commands - ```sh git checkout master @@ -36,6 +30,10 @@ git tag git push origin --tags ``` +**Additional resources** + +<https://git-scm.com/book/en/Git-Basics-Tagging> + <!-- ## Troubleshooting Include any troubleshooting steps that you can foresee. If you know beforehand what issues diff --git a/doc/university/training/topics/unstage.md b/doc/university/training/topics/unstage.md index fa1f63f9ec4..d7482bf2bd5 100644 --- a/doc/university/training/topics/unstage.md +++ b/doc/university/training/topics/unstage.md @@ -4,8 +4,6 @@ comments: false # Unstage -## Unstage - - To remove files from stage use reset HEAD where HEAD is the last commit of the current branch. This will unstage the file but maintain the modifications. ```bash diff --git a/doc/update/upgrading_from_source.md b/doc/update/upgrading_from_source.md index 0aef40262c9..df35638cba2 100644 --- a/doc/update/upgrading_from_source.md +++ b/doc/update/upgrading_from_source.md @@ -47,8 +47,8 @@ sudo service gitlab stop ### 3. Update Ruby -NOTE: Beginning in GitLab 11.6, we only support Ruby 2.5 or higher, and dropped -support for Ruby 2.4. Be sure to upgrade if necessary. +NOTE: Beginning in GitLab 12.2, we only support Ruby 2.6 or higher, and dropped +support for Ruby 2.5. Be sure to upgrade if necessary. You can check which version you are running with `ruby -v`. diff --git a/doc/user/admin_area/settings/img/rate_limits_on_raw_endpoints.png b/doc/user/admin_area/settings/img/rate_limits_on_raw_endpoints.png Binary files differnew file mode 100644 index 00000000000..c32eb93c8a8 --- /dev/null +++ b/doc/user/admin_area/settings/img/rate_limits_on_raw_endpoints.png diff --git a/doc/user/admin_area/settings/rate_limits_on_raw_endpoints.md b/doc/user/admin_area/settings/rate_limits_on_raw_endpoints.md new file mode 100644 index 00000000000..b2d56be154b --- /dev/null +++ b/doc/user/admin_area/settings/rate_limits_on_raw_endpoints.md @@ -0,0 +1,20 @@ +--- +type: reference +--- + +# Rate limits on raw endpoints **(CORE ONLY)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/30829) in GitLab 12.2. + +This setting allows you to rate limit the requests to raw endpoints, defaults to `300` requests per minute. +It can be modified in **Admin Area > Network > Performance Optimization**. + +For example, requests over `300` per minute to `https://gitlab.com/gitlab-org/gitlab-ce/raw/master/app/controllers/application_controller.rb` will be blocked. + +![Rate limits on raw endpoints](img/rate_limits_on_raw_endpoints.png) + +This limit is: + +- Applied independently per project, per commit and per file path. +- Not applied per IP address. +- Active by default. To disable, set the option to `0`. diff --git a/doc/user/application_security/dependency_list/img/dependency_list_v12_2.png b/doc/user/application_security/dependency_list/img/dependency_list_v12_2.png Binary files differnew file mode 100644 index 00000000000..af9cee08d71 --- /dev/null +++ b/doc/user/application_security/dependency_list/img/dependency_list_v12_2.png diff --git a/doc/user/application_security/dependency_list/index.md b/doc/user/application_security/dependency_list/index.md new file mode 100644 index 00000000000..38c38bbd8a9 --- /dev/null +++ b/doc/user/application_security/dependency_list/index.md @@ -0,0 +1,49 @@ +# Dependency List **(ULTIMATE)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/10075) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.0. + +The Dependency list allows you to see your project's dependencies, and key +details about them, including their known vulnerabilities. To see it, +navigate to **Security & Compliance > Dependency List** in your project's +sidebar. + +## Requirements + +1. The [Dependency Scanning](../dependency_scanning/index.md) CI job must be + configured for your project. +1. Your project uses at least one of the + [languages and package managers](../dependency_scanning/index.md#supported-languages-and-package-managers) + supported by Gemnasium. + +## Viewing dependencies + +![Dependency List](img/dependency_list_v12_2.png) + +Dependencies are displayed with the following information: + +| Field | Description | +| --------- | ----------- | +| Status | Displays whether or not the dependency has any known vulnerabilities | +| Component | The dependency's name | +| Version | The exact locked version of the dependency your project uses | +| Packager | The packager used to install the depedency | +| Location | A link to the packager-specific lockfile in your project that declared the dependency | + +Dependencies shown are initially sorted by their names. They can also be sorted +by the packager they were installed by, or by the severity of their known +vulnerabilities. + +There is a second list under the `Vulnerable components` tab displaying only +those dependencies with known vulnerabilities. If there are none, this tab is +disabled. + +### Vulnerabilities + +If a dependency has known vulnerabilities, they can be viewed by clicking on the +`Status` cell of that dependency. The severity and description of each +vulnerability will then be displayed below it. + +## Downloading the Dependency List + +Your project's full list of dependencies and their details can be downloaded in +`JSON` format by clicking on the download button. diff --git a/doc/user/application_security/dependency_scanning/index.md b/doc/user/application_security/dependency_scanning/index.md index 10b4d9d4c7c..3148ec63c79 100644 --- a/doc/user/application_security/dependency_scanning/index.md +++ b/doc/user/application_security/dependency_scanning/index.md @@ -327,16 +327,11 @@ Once a vulnerability is found, you can interact with it. Read more on how to For more information about the vulnerabilities database update, check the [maintenance table](../index.md#maintenance-and-update-of-the-vulnerabilities-database). -## Dependency List +## Dependency List **(ULTIMATE)** -> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/10075) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.0. - -An additional benefit of Dependency Scanning is the ability to get a list of your -project's dependencies with their versions. This list can be generated only for -[languages and package managers](#supported-languages-and-package-managers) -supported by Gemnasium. - -To see the generated dependency list, navigate to your project's **Security & Compliance > Dependency List**. +An additional benefit of Dependency Scanning is the ability to view your +project's dependencies and their known vulnerabilities. Read more about +the [Dependency List](../dependency_list/index.md). ## Versioning and release process diff --git a/doc/user/application_security/index.md b/doc/user/application_security/index.md index 4dcb416c110..83ea0ea3386 100644 --- a/doc/user/application_security/index.md +++ b/doc/user/application_security/index.md @@ -25,6 +25,7 @@ GitLab can scan and report any vulnerabilities found in your project. | Secure scanning tool | Description | |:-----------------------------------------------------------------------------|:-----------------------------------------------------------------------| | [Container Scanning](container_scanning/index.md) **(ULTIMATE)** | Scan Docker containers for known vulnerabilities. | +| [Dependency List](dependency_list/index.md) **(ULTIMATE)** | View your project's dependencies and their known vulnerabilities. | | [Dependency Scanning](dependency_scanning/index.md) **(ULTIMATE)** | Analyze your dependencies for known vulnerabilities. | | [Dynamic Application Security Testing (DAST)](dast/index.md) **(ULTIMATE)** | Analyze running web applications for known vulnerabilities. | | [License Management](license_management/index.md) **(ULTIMATE)** | Search your project's dependencies for their licenses. | diff --git a/doc/user/asciidoc.md b/doc/user/asciidoc.md index df86b2a1cbe..862316b57da 100644 --- a/doc/user/asciidoc.md +++ b/doc/user/asciidoc.md @@ -277,11 +277,11 @@ source - a listing that is embellished with (colorized) syntax highlighting ---- ``` -````asciidoc +~~~asciidoc \```language fenced code - a shorthand syntax for the source block \``` -```` +~~~ ```asciidoc [,attribution,citetitle] diff --git a/doc/user/markdown.md b/doc/user/markdown.md index edb1e904f2b..6cfc8b6429b 100644 --- a/doc/user/markdown.md +++ b/doc/user/markdown.md @@ -533,6 +533,10 @@ This snippet links to `<wiki_root>/miscellaneous.md`: [Link to Related Page](/miscellaneous.md) ``` +### Embedding metrics in GitLab Flavored Markdown + +Metric charts can be embedded within GitLab Flavored Markdown. See [Embedding Metrics within GitLab flavored Markdown](../user/project/integrations/prometheus.md#embedding-metric-charts-within-gitlab-flavored-markdown) for more details. + ## Standard markdown and extensions in GitLab All standard markdown formatting should work as expected within GitLab. Some standard diff --git a/doc/user/permissions.md b/doc/user/permissions.md index 1d457099ebc..16684b9f72b 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -233,6 +233,16 @@ nested groups if you have membership in one of its parents. To learn more, read through the documentation on [subgroups memberships](group/subgroups/index.md#membership). +## Guest User + +Create a user and assign to a project with a role as `Guest` user, this user +will be considered as guest user by GitLab and will not take up the license. +There is no specific `Guest` role for newly created users. If this user will +be assigned a higher role to any of the projects and groups then this user will +take a license seat. If a user creates a project this user becomes a maintainer, +therefore, takes up a license seat as well, in order to prevent this you have +to go and edit user profile and mark the user as External. + ## External users permissions In cases where it is desired that a user has access only to some internal or diff --git a/doc/user/profile/preferences.md b/doc/user/profile/preferences.md index 5bd4b263a58..82a6d2b3703 100644 --- a/doc/user/profile/preferences.md +++ b/doc/user/profile/preferences.md @@ -87,6 +87,16 @@ You have 8 options here that you can use for your default dashboard view: - Assigned Merge Requests - Operations Dashboard **(PREMIUM)** +### Group overview content + +The **Group overview content** dropdown allows you to choose what information is +displayed on a group’s home page. + +You can choose between 2 options: + +- Details (default) +- [Security dashboard](../application_security/security_dashboard/index.md) **(ULTIMATE)** + ### Project overview content The project overview content setting allows you to choose what content you want to diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md index ffaa07cb3a4..cf3a3fef79f 100644 --- a/doc/user/project/clusters/index.md +++ b/doc/user/project/clusters/index.md @@ -173,6 +173,9 @@ Starting from [GitLab 12.1](https://gitlab.com/gitlab-org/gitlab-ce/issues/55902 ### Add existing Kubernetes cluster +NOTE: **Note:** +Kubernetes integration is not supported for arm64 clusters. See the issue [Helm Tiller fails to install on arm64 cluster](https://gitlab.com/gitlab-org/gitlab-ce/issues/64044) for details. + To add an existing Kubernetes cluster to your project: 1. Navigate to your project's **Operations > Kubernetes** page. diff --git a/doc/user/project/cycle_analytics.md b/doc/user/project/cycle_analytics.md index 6707b88c317..424bee6e9f1 100644 --- a/doc/user/project/cycle_analytics.md +++ b/doc/user/project/cycle_analytics.md @@ -1,5 +1,7 @@ # Cycle Analytics +> - [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/12077) at a group level in [GitLab Premium and Silver](https://about.gitlab.com/pricing/) 12.2 (enabled by feature flag `analytics`). + Cycle Analytics measures the time spent to go from an [idea to production] - also known as cycle time - for each of your projects. Cycle Analytics displays the median time for an idea to reach production, along with the time typically spent in each DevOps stage along the way. @@ -13,10 +15,16 @@ calculates a separate median for each stage. ## Overview -You can find the Cycle Analytics page under your project's **Project ➔ Cycle -Analytics** tab. +Cycle Analytics are available at a: + +- Group level from the top navigation bar **Analytics > Cycle Analytics**. **(PREMIUM)** + + In the future, multiple groups will be selectable which will effectively make this an + instance-level feature. + +- Project level from a project's **Project > Cycle Analytics**. -![Cycle Analytics landing page](img/cycle_analytics_landing_page.png) + ![Cycle Analytics landing page](img/cycle_analytics_landing_page.png) There are seven stages that are tracked as part of the Cycle Analytics calculations. @@ -134,7 +142,7 @@ A few notes: ## Permissions -The current permissions on the Cycle Analytics dashboard are: +The current permissions on the Project Cycle Analytics dashboard are: - Public projects - anyone can access - Internal projects - any authenticated user can access @@ -142,6 +150,18 @@ The current permissions on the Cycle Analytics dashboard are: You can [read more about permissions][permissions] in general. +NOTE: **Note:** +As of GitLab 12.2, the project-level page is deprecated. You should access +project-level Cycle Analytics from **Analytics > Cycle Analytics** in the top +navigation bar. We will ensure that the same project-level functionality is available +to CE users in the new analytics space. + +For Cycle Analytics functionality introduced in GitLab 12.2 and later: + +- Users must have Reporter access or above. +- Features are available only on + [Premium or Silver tiers](https://about.gitlab.com/pricing/) and above. + ## More resources Learn more about Cycle Analytics in the following resources: diff --git a/doc/user/project/description_templates.md b/doc/user/project/description_templates.md index 196874fdc86..f53dc056010 100644 --- a/doc/user/project/description_templates.md +++ b/doc/user/project/description_templates.md @@ -55,7 +55,7 @@ changes you made after picking the template and return it to its initial status. ![Description templates](img/description_templates.png) -## Setting a default template for issues and merge requests **(STARTER)** +## Setting a default template for merge requests and issues **(STARTER)** > **Notes:** > @@ -66,20 +66,20 @@ changes you made after picking the template and return it to its initial status. > - Templates for merge requests were [introduced][ee-7478ece] in GitLab EE 6.9. The visibility of issues and/or merge requests should be set to either "Everyone -with access" or "Only Project Members" in your project's **Settings** otherwise the +with access" or "Only Project Members" in your project's **Settings / Visibility, project features, permissions** section, otherwise the template text areas won't show. This is the default behavior so in most cases you should be fine. -Go to your project's **Settings** and fill in the "Default description template -for issues" and "Default description template for merge requests" text areas -for issues and merge requests respectively. Since GitLab issues and merge -request support [Markdown](../markdown.md), you can use special markup like +Go to your project's **Settings** and under the **Merge requests** header, click *Expand* and fill in the "Default description template +for merge requests" text area. Under the **Default issue template**, click *Expand* and fill in "Default description template for issues" text area. Since GitLab merge request and issues + support [Markdown](../markdown.md), you can use special markup like headings, lists, etc. -![Default description templates](img/description_templates_default_settings.png) +![Default merge request description templates](img/description_templates_merge_request_settings.png) +![Default issue description templates](img/description_templates_issue_settings.png) After you add the description, hit **Save changes** for the settings to take -effect. Now, every time a new issue or merge request is created, it will be +effect. Now, every time a new merge request or issue is created, it will be pre-filled with the text you entered in the template(s). ## Description template example diff --git a/doc/user/project/img/description_templates_default_settings.png b/doc/user/project/img/description_templates_default_settings.png Binary files differdeleted file mode 100644 index ab314e83d06..00000000000 --- a/doc/user/project/img/description_templates_default_settings.png +++ /dev/null diff --git a/doc/user/project/img/description_templates_issue_settings.png b/doc/user/project/img/description_templates_issue_settings.png Binary files differnew file mode 100644 index 00000000000..53328108835 --- /dev/null +++ b/doc/user/project/img/description_templates_issue_settings.png diff --git a/doc/user/project/img/description_templates_merge_request_settings.png b/doc/user/project/img/description_templates_merge_request_settings.png Binary files differnew file mode 100644 index 00000000000..eda264f7f37 --- /dev/null +++ b/doc/user/project/img/description_templates_merge_request_settings.png diff --git a/doc/user/project/index.md b/doc/user/project/index.md index 45e96437517..30ff0e9ff07 100644 --- a/doc/user/project/index.md +++ b/doc/user/project/index.md @@ -99,6 +99,7 @@ When you create a project in GitLab, you'll have access to a large number of - [NPM packages](packages/npm_registry.md): your private NPM package registry in GitLab. **(PREMIUM)** - [Code owners](code_owners.md): specify code owners for certain files **(STARTER)** - [License Management](../application_security/license_management/index.md): approve and blacklist licenses for projects. **(ULTIMATE)** +- [Dependency List](../application_security/dependency_list/index.md): view project dependencies. **(ULTIMATE)** ### Project integrations diff --git a/doc/user/project/integrations/img/embed_metrics.png b/doc/user/project/integrations/img/embed_metrics.png Binary files differnew file mode 100644 index 00000000000..6f9660c9aec --- /dev/null +++ b/doc/user/project/integrations/img/embed_metrics.png diff --git a/doc/user/project/integrations/mattermost.md b/doc/user/project/integrations/mattermost.md index ea58a08e127..6e0f39956d3 100644 --- a/doc/user/project/integrations/mattermost.md +++ b/doc/user/project/integrations/mattermost.md @@ -14,7 +14,7 @@ To enable Mattermost integration you must create an incoming webhook integration 1. Save it, copy the **Webhook URL**, we'll need this later for GitLab. There might be some cases that Incoming Webhooks are blocked by admin, ask your mattermost admin to enable -it on `https://mattermost.example/admin_console/integrations/custom`. +it on **Mattermost System Console > Integrations > Integration Management**, or on **Mattermost System Console > Integrations > Custom Integrations** in Mattermost versions 5.11 and earlier. Display name override is not enabled by default, you need to ask your admin to enable it on that same section. diff --git a/doc/user/project/integrations/prometheus.md b/doc/user/project/integrations/prometheus.md index 44439b59e77..aa7db97c413 100644 --- a/doc/user/project/integrations/prometheus.md +++ b/doc/user/project/integrations/prometheus.md @@ -354,6 +354,27 @@ Prometheus server. ![Merge Request with Performance Impact](img/merge_request_performance.png) +## Embedding metric charts within Gitlab Flavored Markdown + +> [Introduced][ce-29691] in GitLab 12.2. +> Requires [Kubernetes](prometheus_library/kubernetes.md) metrics. + +It is possible to display metrics charts within [GitLab Flavored Markdown](../../markdown.md#gitlab-flavored-markdown-gfm). + +To display a metric chart, include a link of the form `https://<root_url>/<project>/environments/<environment_id>/metrics`. + +The following requirements must be met for the metric to unfurl: + +- The `<environment_id>` must correspond to a real environment. +- Prometheus must be monitoring the environment. +- The GitLab instance must be configured to receive data from the environment. +- The user must be allowed access to the monitoring dashboard for the environment ([Reporter or higher](../../permissions.md)). +- The dashboard must have data within the last 8 hours. + + If all of the above are true, then the metric will unfurl as seen below: + +![Embedded Metrics](img/embed_metrics.png) + ## Troubleshooting If the "No data found" screen continues to appear, it could be due to: @@ -376,4 +397,5 @@ If the "No data found" screen continues to appear, it could be due to: [ci-environment-slug]: ../../../ci/variables/#predefined-environment-variables [ce-8935]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8935 [ce-10408]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10408 +[ce-29691]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/29691 [promgldocs]: ../../../administration/monitoring/prometheus/index.md diff --git a/doc/user/project/issues/issue_data_and_actions.md b/doc/user/project/issues/issue_data_and_actions.md index 7b031f83cb1..d7d168710ef 100644 --- a/doc/user/project/issues/issue_data_and_actions.md +++ b/doc/user/project/issues/issue_data_and_actions.md @@ -50,7 +50,12 @@ The button to do this has a different label depending on whether the issue is al #### 3. Assignee -An issue can be assigned to yourself, another person, or [many people](#31-multiple-assignees-STARTER). +An issue can be assigned to: + +- Yourself. +- Another person. +- [Many people](#31-multiple-assignees-STARTER). **(STARTER)** + The assignee(s) can be changed as often as needed. The idea is that the assignees are responsible for that issue until it's reassigned to someone else to take it from there. When assigned to someone, it will appear in their assigned issues list. diff --git a/doc/user/project/merge_requests/index.md b/doc/user/project/merge_requests/index.md index 7ff30d1b813..8a82b163481 100644 --- a/doc/user/project/merge_requests/index.md +++ b/doc/user/project/merge_requests/index.md @@ -287,6 +287,8 @@ as pushing changes: - Set the target of the merge request to a particular branch. - Set the merge request to merge when its pipeline succeeds. - Set the merge request to remove the source branch when it's merged. +- Set the title of the merge request to a particular title. +- Set the description of the merge request to a particular description. ### Create a new merge request using git push options @@ -332,6 +334,8 @@ git push -o merge_request.create -o merge_request.merge_when_pipeline_succeeds ### Set removing the source branch using git push options +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/64320) in GitLab 12.2. + To set an existing merge request to remove the source branch when the merge request is merged, the `merge_request.remove_source_branch` push option can be used: @@ -345,6 +349,8 @@ You can also use this push option in addition to the ### Set merge request title using git push options +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/64320) in GitLab 12.2. + To set the title of an existing merge request, use the `merge_request.title` push option: @@ -357,6 +363,8 @@ You can also use this push option in addition to the ### Set merge request description using git push options +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/64320) in GitLab 12.2. + To set the description of an existing merge request, use the `merge_request.description` push option: @@ -480,15 +488,6 @@ without having to check the entire job log. [Read more about JUnit test reports](../../../ci/junit_test_reports.md). -## Live preview with Review Apps - -If you configured [Review Apps](https://about.gitlab.com/features/review-apps/) for your project, -you can preview the changes submitted to a feature-branch through a merge request -in a per-branch basis. No need to checkout the branch, install and preview locally; -all your changes will be available to preview by anyone with the Review Apps link. - -[Read more about Review Apps.](../../../ci/review_apps/index.md) - ## Merge request diff file navigation When reviewing changes in the **Changes** tab the diff can be navigated using diff --git a/lib/api/commits.rb b/lib/api/commits.rb index e4f4e79cd46..a2f3e87ebd2 100644 --- a/lib/api/commits.rb +++ b/lib/api/commits.rb @@ -43,7 +43,7 @@ module API path = params[:path] before = params[:until] after = params[:since] - ref = params[:ref_name] || user_project.try(:default_branch) || 'master' unless params[:all] + ref = params[:ref_name].presence || user_project.try(:default_branch) || 'master' unless params[:all] offset = (params[:page] - 1) * params[:per_page] all = params[:all] with_stats = params[:with_stats] diff --git a/lib/api/discussions.rb b/lib/api/discussions.rb index cc62ce22a1b..6c1acc3963f 100644 --- a/lib/api/discussions.rb +++ b/lib/api/discussions.rb @@ -4,6 +4,7 @@ module API class Discussions < Grape::API include PaginationParams helpers ::API::Helpers::NotesHelpers + helpers ::RendersNotes before { authenticate! } @@ -23,21 +24,15 @@ module API requires :noteable_id, types: [Integer, String], desc: 'The ID of the noteable' use :pagination end - # rubocop: disable CodeReuse/ActiveRecord + get ":id/#{noteables_path}/:noteable_id/discussions" do noteable = find_noteable(parent_type, params[:id], noteable_type, params[:noteable_id]) - notes = noteable.notes - .inc_relations_for_view - .includes(:noteable) - .fresh - - notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) } + notes = readable_discussion_notes(noteable) discussions = Kaminari.paginate_array(Discussion.build_collection(notes, noteable)) present paginate(discussions), with: Entities::Discussion end - # rubocop: enable CodeReuse/ActiveRecord desc "Get a single #{noteable_type.to_s.downcase} discussion" do success Entities::Discussion @@ -226,13 +221,24 @@ module API helpers do # rubocop: disable CodeReuse/ActiveRecord - def readable_discussion_notes(noteable, discussion_id) + def readable_discussion_notes(noteable, discussion_id = nil) notes = noteable.notes - .where(discussion_id: discussion_id) + notes = notes.where(discussion_id: discussion_id) if discussion_id + notes = notes .inc_relations_for_view .includes(:noteable) .fresh + # Without RendersActions#prepare_notes_for_rendering, + # Note#cross_reference_not_visible_for? will attempt to render + # Markdown references mentioned in the note to see whether they + # should be redacted. For notes that reference a commit, this + # would also incur a Gitaly call to verify the commit exists. + # + # With prepare_notes_for_rendering, we can avoid Gitaly calls + # because notes are redacted if they point to projects that + # cannot be accessed by the user. + notes = prepare_notes_for_rendering(notes) notes.reject { |n| n.cross_reference_not_visible_for?(current_user) } end # rubocop: enable CodeReuse/ActiveRecord diff --git a/lib/api/settings.rb b/lib/api/settings.rb index 196ef1fcdfa..c36ee5af63f 100644 --- a/lib/api/settings.rb +++ b/lib/api/settings.rb @@ -125,6 +125,12 @@ module API optional :instance_statistics_visibility_private, type: Boolean, desc: 'When set to `true` Instance statistics will only be available to admins' optional :local_markdown_version, type: Integer, desc: "Local markdown version, increase this value when any cached markdown should be invalidated" optional :allow_local_requests_from_hooks_and_services, type: Boolean, desc: 'Deprecated: Use :allow_local_requests_from_web_hooks_and_services instead. Allow requests to the local network from hooks and services.' # support legacy names, can be removed in v5 + optional :snowplow_enabled, type: Grape::API::Boolean, desc: 'Enable Snowplow tracking' + given snowplow_enabled: ->(val) { val } do + requires :snowplow_collector_hostname, type: String, desc: 'The Snowplow collector hostname' + optional :snowplow_cookie_domain, type: String, desc: 'The Snowplow cookie domain' + optional :snowplow_site_id, type: String, desc: 'The Snowplow site name / application ic' + end ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type| optional :"#{type}_key_restriction", diff --git a/lib/api/todos.rb b/lib/api/todos.rb index 7260ecfb5ee..404675bfaec 100644 --- a/lib/api/todos.rb +++ b/lib/api/todos.rb @@ -13,6 +13,13 @@ module API 'issues' => ->(iid) { find_project_issue(iid) } }.freeze + helpers do + # EE::API::Todos would override this method + def find_todos + TodosFinder.new(current_user, params).execute + end + end + params do requires :id, type: String, desc: 'The ID of a project' end @@ -41,10 +48,6 @@ module API resource :todos do helpers do - def find_todos - TodosFinder.new(current_user, params).execute - end - def issuable_and_awardable?(type) obj_type = Object.const_get(type) @@ -107,3 +110,5 @@ module API end end end + +API::Todos.prepend_if_ee('EE::API::Todos') diff --git a/lib/banzai/filter/inline_metrics_filter.rb b/lib/banzai/filter/inline_metrics_filter.rb index 0120cc37d6f..c5a328c21b2 100644 --- a/lib/banzai/filter/inline_metrics_filter.rb +++ b/lib/banzai/filter/inline_metrics_filter.rb @@ -15,17 +15,6 @@ module Banzai ) end - # Endpoint FE should hit to collect the appropriate - # chart information - def metrics_dashboard_url(params) - Gitlab::Metrics::Dashboard::Url.build_dashboard_url( - params['namespace'], - params['project'], - params['environment'], - embedded: true - ) - end - # Search params for selecting metrics links. A few # simple checks is enough to boost performance without # the cost of doing a full regex match. @@ -38,6 +27,28 @@ module Banzai def link_pattern Gitlab::Metrics::Dashboard::Url.regex end + + private + + # Endpoint FE should hit to collect the appropriate + # chart information + def metrics_dashboard_url(params) + Gitlab::Metrics::Dashboard::Url.build_dashboard_url( + params['namespace'], + params['project'], + params['environment'], + embedded: true, + **query_params(params['url']) + ) + end + + # Parses query params out from full url string into hash. + # + # Ex) 'https://<root>/<project>/<environment>/metrics?title=Title&group=Group' + # --> { title: 'Title', group: 'Group' } + def query_params(url) + Gitlab::Metrics::Dashboard::Url.parse_query(url) + end end end end diff --git a/lib/container_registry/tag.rb b/lib/container_registry/tag.rb index ef41dc560c9..ebea84fa1ca 100644 --- a/lib/container_registry/tag.rb +++ b/lib/container_registry/tag.rb @@ -6,6 +6,9 @@ module ContainerRegistry attr_reader :repository, :name + # https://github.com/docker/distribution/commit/3150937b9f2b1b5b096b2634d0e7c44d4a0f89fb + TAG_NAME_REGEX = /^[\w][\w.-]{0,127}$/.freeze + delegate :registry, :client, to: :repository delegate :revision, :short_revision, to: :config_blob, allow_nil: true @@ -13,6 +16,10 @@ module ContainerRegistry @repository, @name = repository, name end + def valid_name? + !name.match(TAG_NAME_REGEX).nil? + end + def valid? manifest.present? end diff --git a/lib/expand_variables.rb b/lib/expand_variables.rb index c83cec9dc4a..45af30f46dc 100644 --- a/lib/expand_variables.rb +++ b/lib/expand_variables.rb @@ -3,6 +3,20 @@ module ExpandVariables class << self def expand(value, variables) + variables_hash = nil + + value.gsub(/\$([a-zA-Z_][a-zA-Z0-9_]*)|\${\g<1>}|%\g<1>%/) do + variables_hash ||= transform_variables(variables) + variables_hash[$1 || $2] + end + end + + private + + def transform_variables(variables) + # Lazily initialise variables + variables = variables.call if variables.is_a?(Proc) + # Convert hash array to variables if variables.is_a?(Array) variables = variables.reduce({}) do |hash, variable| @@ -11,9 +25,7 @@ module ExpandVariables end end - value.gsub(/\$([a-zA-Z_][a-zA-Z0-9_]*)|\${\g<1>}|%\g<1>%/) do - variables[$1 || $2] - end + variables end end end diff --git a/lib/gitlab/ci/pipeline/chain/populate.rb b/lib/gitlab/ci/pipeline/chain/populate.rb index 0405292a25b..65029f5ce7f 100644 --- a/lib/gitlab/ci/pipeline/chain/populate.rb +++ b/lib/gitlab/ci/pipeline/chain/populate.rb @@ -23,12 +23,17 @@ module Gitlab @command.seeds_block&.call(pipeline) ## - # Populate pipeline with all stages, and stages with builds. + # Gather all runtime build/stage errors # - pipeline.stage_seeds.each do |stage| - pipeline.stages << stage.to_resource + if seeds_errors = pipeline.stage_seeds.flat_map(&:errors).compact.presence + return error(seeds_errors.join("\n")) end + ## + # Populate pipeline with all stages, and stages with builds. + # + pipeline.stages = pipeline.stage_seeds.map(&:to_resource) + if pipeline.stages.none? return error('No stages / jobs for this pipeline.') end diff --git a/lib/gitlab/ci/pipeline/seed/base.rb b/lib/gitlab/ci/pipeline/seed/base.rb index 1fd3a61017f..e9e22569ae0 100644 --- a/lib/gitlab/ci/pipeline/seed/base.rb +++ b/lib/gitlab/ci/pipeline/seed/base.rb @@ -13,6 +13,10 @@ module Gitlab raise NotImplementedError end + def errors + raise NotImplementedError + end + def to_resource raise NotImplementedError end diff --git a/lib/gitlab/ci/pipeline/seed/build.rb b/lib/gitlab/ci/pipeline/seed/build.rb index ab0d4c38ab6..7ec03d132c0 100644 --- a/lib/gitlab/ci/pipeline/seed/build.rb +++ b/lib/gitlab/ci/pipeline/seed/build.rb @@ -9,10 +9,15 @@ module Gitlab delegate :dig, to: :@attributes + # When the `ci_dag_limit_needs` is enabled it uses the lower limit + LOW_NEEDS_LIMIT = 5 + HARD_NEEDS_LIMIT = 50 + def initialize(pipeline, attributes, previous_stages) @pipeline = pipeline @attributes = attributes @previous_stages = previous_stages + @needs_attributes = dig(:needs_attributes) @only = Gitlab::Ci::Build::Policy .fabricate(attributes.delete(:only)) @@ -27,8 +32,15 @@ module Gitlab def included? strong_memoize(:inclusion) do all_of_only? && - none_of_except? && - all_of_needs? + none_of_except? + end + end + + def errors + return unless included? + + strong_memoize(:errors) do + needs_errors end end @@ -45,9 +57,24 @@ module Gitlab end def bridge? - @attributes.to_h.dig(:options, :trigger).present? + attributes_hash = @attributes.to_h + attributes_hash.dig(:options, :trigger).present? || + (attributes_hash.dig(:options, :bridge_needs).instance_of?(Hash) && + attributes_hash.dig(:options, :bridge_needs, :pipeline).present?) end + def to_resource + strong_memoize(:resource) do + if bridge? + ::Ci::Bridge.new(attributes) + else + ::Ci::Build.new(attributes) + end + end + end + + private + def all_of_only? @only.all? { |spec| spec.satisfied_by?(@pipeline, self) } end @@ -56,24 +83,30 @@ module Gitlab @except.none? { |spec| spec.satisfied_by?(@pipeline, self) } end - def all_of_needs? - return true unless Feature.enabled?(:ci_dag_support, @pipeline.project) - return true if dig(:needs_attributes).nil? + def needs_errors + return if @needs_attributes.nil? + + if @needs_attributes.size > max_needs_allowed + return [ + "#{name}: one job can only need #{max_needs_allowed} others, but you have listed #{@needs_attributes.size}. " \ + "See needs keyword documentation for more details" + ] + end - dig(:needs_attributes).all? do |need| - @previous_stages.any? do |stage| + @needs_attributes.flat_map do |need| + result = @previous_stages.any? do |stage| stage.seeds_names.include?(need[:name]) end - end + + "#{name}: needs '#{need[:name]}'" unless result + end.compact end - def to_resource - strong_memoize(:resource) do - if bridge? - ::Ci::Bridge.new(attributes) - else - ::Ci::Build.new(attributes) - end + def max_needs_allowed + if Feature.enabled?(:ci_dag_limit_needs, @project, default_enabled: true) + LOW_NEEDS_LIMIT + else + HARD_NEEDS_LIMIT end end end diff --git a/lib/gitlab/ci/pipeline/seed/stage.rb b/lib/gitlab/ci/pipeline/seed/stage.rb index 7c737027445..b600df2f656 100644 --- a/lib/gitlab/ci/pipeline/seed/stage.rb +++ b/lib/gitlab/ci/pipeline/seed/stage.rb @@ -33,6 +33,12 @@ module Gitlab end end + def errors + strong_memoize(:errors) do + seeds.flat_map(&:errors).compact + end + end + def seeds_names strong_memoize(:seeds_names) do seeds.map(&:name).to_set diff --git a/lib/gitlab/ci/yaml_processor.rb b/lib/gitlab/ci/yaml_processor.rb index 998130e5bd0..2e1eab270ff 100644 --- a/lib/gitlab/ci/yaml_processor.rb +++ b/lib/gitlab/ci/yaml_processor.rb @@ -55,7 +55,8 @@ module Gitlab parallel: job[:parallel], instance: job[:instance], start_in: job[:start_in], - trigger: job[:trigger] + trigger: job[:trigger], + bridge_needs: job[:needs] }.compact }.compact end diff --git a/lib/gitlab/danger/helper.rb b/lib/gitlab/danger/helper.rb index c0a12318990..332ca8bf9b8 100644 --- a/lib/gitlab/danger/helper.rb +++ b/lib/gitlab/danger/helper.rb @@ -113,7 +113,7 @@ module Gitlab yarn\.lock )\z}x => :frontend, - %r{\A(ee/)?db/} => :database, + %r{\A(ee/)?db/(?!fixtures)[^/]+} => :database, %r{\A(ee/)?lib/gitlab/(database|background_migration|sql|github_import)(/|\.rb)} => :database, %r{\A(app/models/project_authorization|app/services/users/refresh_authorized_projects_service)(/|\.rb)} => :database, %r{\Arubocop/cop/migration(/|\.rb)} => :database, diff --git a/lib/gitlab/data_builder/push.rb b/lib/gitlab/data_builder/push.rb index 40bda3410e1..37fadb47736 100644 --- a/lib/gitlab/data_builder/push.rb +++ b/lib/gitlab/data_builder/push.rb @@ -60,7 +60,8 @@ module Gitlab # rubocop:disable Metrics/ParameterLists def build( project:, user:, ref:, oldrev: nil, newrev: nil, - commits: [], commits_count: nil, message: nil, push_options: {}) + commits: [], commits_count: nil, message: nil, push_options: {}, + with_changed_files: true) commits = Array(commits) @@ -75,7 +76,7 @@ module Gitlab # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/38259 commit_attrs = Gitlab::GitalyClient.allow_n_plus_1_calls do commits_limited.map do |commit| - commit.hook_attrs(with_changed_files: true) + commit.hook_attrs(with_changed_files: with_changed_files) end end diff --git a/lib/gitlab/git_post_receive.rb b/lib/gitlab/git_post_receive.rb index 6c7f23a673c..2a8bcd015a8 100644 --- a/lib/gitlab/git_post_receive.rb +++ b/lib/gitlab/git_post_receive.rb @@ -33,6 +33,23 @@ module Gitlab end end + def includes_tags? + enum_for(:changes_refs).any? do |_oldrev, _newrev, ref| + Gitlab::Git.tag_ref?(ref) + end + end + + def includes_default_branch? + # If the branch doesn't have a default branch yet, we presume the + # first branch pushed will be the default. + return true unless project.default_branch.present? + + enum_for(:changes_refs).any? do |_oldrev, _newrev, ref| + Gitlab::Git.branch_ref?(ref) && + Gitlab::Git.branch_name(ref) == project.default_branch + end + end + private def deserialize_changes(changes) diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index 1b7fc5fa10f..bd0f3e70749 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -137,6 +137,7 @@ excluded_attributes: - :packages_enabled - :mirror_last_update_at - :mirror_last_successful_update_at + - :emails_disabled namespaces: - :runners_token - :runners_token_encrypted diff --git a/lib/gitlab/kubernetes/helm.rb b/lib/gitlab/kubernetes/helm.rb index 42c4745ff98..6e4286589c1 100644 --- a/lib/gitlab/kubernetes/helm.rb +++ b/lib/gitlab/kubernetes/helm.rb @@ -3,8 +3,8 @@ module Gitlab module Kubernetes module Helm - HELM_VERSION = '2.12.3'.freeze - KUBECTL_VERSION = '1.11.7'.freeze + HELM_VERSION = '2.14.3'.freeze + KUBECTL_VERSION = '1.11.10'.freeze NAMESPACE = 'gitlab-managed-apps'.freeze SERVICE_ACCOUNT = 'tiller'.freeze CLUSTER_ROLE_BINDING = 'tiller-admin'.freeze diff --git a/lib/gitlab/kubernetes/helm/reset_command.rb b/lib/gitlab/kubernetes/helm/reset_command.rb index 37e1d8573ab..a35ffa34c58 100644 --- a/lib/gitlab/kubernetes/helm/reset_command.rb +++ b/lib/gitlab/kubernetes/helm/reset_command.rb @@ -38,9 +38,9 @@ module Gitlab # Tracking this method to be removed here: # https://gitlab.com/gitlab-org/gitlab-ce/issues/52791#note_199374155 def delete_tiller_replicaset - command = %w[kubectl delete replicaset -n gitlab-managed-apps -l name=tiller] + delete_args = %w[replicaset -n gitlab-managed-apps -l name=tiller] - command.shelljoin + Gitlab::Kubernetes::KubectlCmd.delete(*delete_args) end def reset_helm_command diff --git a/lib/gitlab/kubernetes/kubectl_cmd.rb b/lib/gitlab/kubernetes/kubectl_cmd.rb new file mode 100644 index 00000000000..981eb5681dc --- /dev/null +++ b/lib/gitlab/kubernetes/kubectl_cmd.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Gitlab + module Kubernetes + module KubectlCmd + class << self + def delete(*args) + %w(kubectl delete).concat(args).shelljoin + end + + def apply_file(filename, *args) + raise ArgumentError, "filename is not present" unless filename.present? + + %w(kubectl apply -f).concat([filename], args).shelljoin + end + end + end + end +end diff --git a/lib/gitlab/metrics/dashboard/url.rb b/lib/gitlab/metrics/dashboard/url.rb index b197e7ca86b..94f8b2e02b1 100644 --- a/lib/gitlab/metrics/dashboard/url.rb +++ b/lib/gitlab/metrics/dashboard/url.rb @@ -21,14 +21,26 @@ module Gitlab \/(?<environment>\d+) \/metrics (?<query> - \?[a-z0-9_=-]+ - (&[a-z0-9_=-]+)* + \?[a-zA-Z0-9%.()+_=-]+ + (&[a-zA-Z0-9%.()+_=-]+)* )? (?<anchor>\#[a-z0-9_-]+)? ) }x end + # Parses query params out from full url string into hash. + # + # Ex) 'https://<root>/<project>/<environment>/metrics?title=Title&group=Group' + # --> { title: 'Title', group: 'Group' } + def parse_query(url) + query_string = URI.parse(url).query.to_s + + CGI.parse(query_string) + .transform_values { |value| value.first } + .symbolize_keys + end + # Builds a metrics dashboard url based on the passed in arguments def build_dashboard_url(*args) Gitlab::Routing.url_helpers.metrics_dashboard_namespace_project_environment_url(*args) diff --git a/lib/gitlab/metrics/samplers/puma_sampler.rb b/lib/gitlab/metrics/samplers/puma_sampler.rb index 4e835f37c04..8a24d4f3663 100644 --- a/lib/gitlab/metrics/samplers/puma_sampler.rb +++ b/lib/gitlab/metrics/samplers/puma_sampler.rb @@ -15,7 +15,6 @@ module Gitlab puma_workers: ::Gitlab::Metrics.gauge(:puma_workers, 'Total number of workers'), puma_running_workers: ::Gitlab::Metrics.gauge(:puma_running_workers, 'Number of active workers'), puma_stale_workers: ::Gitlab::Metrics.gauge(:puma_stale_workers, 'Number of stale workers'), - puma_phase: ::Gitlab::Metrics.gauge(:puma_phase, 'Phase number (increased during phased restarts)'), puma_running: ::Gitlab::Metrics.gauge(:puma_running, 'Number of running threads'), puma_queued_connections: ::Gitlab::Metrics.gauge(:puma_queued_connections, 'Number of connections in that worker\'s "todo" set waiting for a worker thread'), puma_active_connections: ::Gitlab::Metrics.gauge(:puma_active_connections, 'Number of threads processing a request'), @@ -54,7 +53,6 @@ module Gitlab last_status = worker['last_status'] labels = { worker: "worker_#{worker['index']}" } - metrics[:puma_phase].set(labels, worker['phase']) set_worker_metrics(last_status, labels) if last_status.present? end end @@ -76,7 +74,6 @@ module Gitlab metrics[:puma_workers].set(labels, stats['workers']) metrics[:puma_running_workers].set(labels, stats['booted_workers']) metrics[:puma_stale_workers].set(labels, stats['old_workers']) - metrics[:puma_phase].set(labels, stats['phase']) end def set_worker_metrics(stats, labels = {}) diff --git a/lib/gitlab/project_template.rb b/lib/gitlab/project_template.rb index dbf469a44c1..fa1d1203842 100644 --- a/lib/gitlab/project_template.rb +++ b/lib/gitlab/project_template.rb @@ -24,6 +24,14 @@ module Gitlab "#{preview}.git" end + def project_path + URI.parse(preview).path.sub(%r{\A/}, '') + end + + def uri_encoded_project_path + ERB::Util.url_encode(project_path) + end + def ==(other) name == other.name && title == other.title end diff --git a/lib/gitlab/snowplow_tracker.rb b/lib/gitlab/snowplow_tracker.rb new file mode 100644 index 00000000000..9f12513e09e --- /dev/null +++ b/lib/gitlab/snowplow_tracker.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'snowplow-tracker' + +module Gitlab + module SnowplowTracker + NAMESPACE = 'cf' + + class << self + def track_event(category, action, label: nil, property: nil, value: nil, context: nil) + tracker&.track_struct_event(category, action, label, property, value, context, Time.now.to_i) + end + + private + + def tracker + return unless enabled? + + @tracker ||= ::SnowplowTracker::Tracker.new(emitter, subject, NAMESPACE, Gitlab::CurrentSettings.snowplow_site_id) + end + + def subject + ::SnowplowTracker::Subject.new + end + + def emitter + ::SnowplowTracker::Emitter.new(Gitlab::CurrentSettings.snowplow_collector_hostname) + end + + def enabled? + Gitlab::CurrentSettings.snowplow_enabled? + end + end + end +end diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb index 7e3a695e52a..1542905d2ce 100644 --- a/lib/gitlab/usage_data.rb +++ b/lib/gitlab/usage_data.rb @@ -100,9 +100,7 @@ module Gitlab .merge(services_usage) .merge(approximate_counts) }.tap do |data| - if Feature.enabled?(:group_overview_security_dashboard) - data[:counts][:user_preferences] = user_preferences_usage - end + data[:counts][:user_preferences] = user_preferences_usage end end # rubocop: enable CodeReuse/ActiveRecord @@ -142,7 +140,9 @@ module Gitlab Gitlab::UsageDataCounters::WebIdeCounter, Gitlab::UsageDataCounters::NoteCounter, Gitlab::UsageDataCounters::SnippetCounter, - Gitlab::UsageDataCounters::SearchCounter + Gitlab::UsageDataCounters::SearchCounter, + Gitlab::UsageDataCounters::CycleAnalyticsCounter, + Gitlab::UsageDataCounters::SourceCodeCounter ] end diff --git a/lib/gitlab/usage_data_counters/cycle_analytics_counter.rb b/lib/gitlab/usage_data_counters/cycle_analytics_counter.rb new file mode 100644 index 00000000000..1ff4296ef65 --- /dev/null +++ b/lib/gitlab/usage_data_counters/cycle_analytics_counter.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Gitlab::UsageDataCounters + class CycleAnalyticsCounter < BaseCounter + KNOWN_EVENTS = %w[views].freeze + PREFIX = 'cycle_analytics' + end +end diff --git a/lib/gitlab/usage_data_counters/source_code_counter.rb b/lib/gitlab/usage_data_counters/source_code_counter.rb new file mode 100644 index 00000000000..8a1771a7bd1 --- /dev/null +++ b/lib/gitlab/usage_data_counters/source_code_counter.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Gitlab::UsageDataCounters + class SourceCodeCounter < BaseCounter + KNOWN_EVENTS = %w[pushes].freeze + PREFIX = 'source_code' + end +end diff --git a/lib/tasks/gitlab/update_templates.rake b/lib/tasks/gitlab/update_templates.rake index 8267c235a7f..fdcd34320b1 100644 --- a/lib/tasks/gitlab/update_templates.rake +++ b/lib/tasks/gitlab/update_templates.rake @@ -40,7 +40,6 @@ namespace :gitlab do templates.each do |template| params = { - import_url: template.clone_url, namespace_id: tmp_namespace.id, path: template.name, skip_wiki: true @@ -53,22 +52,46 @@ namespace :gitlab do raise "Failed to create project: #{project.errors.messages}" end - loop do - if project.import_finished? - puts "Import finished for #{template.name}" - break + uri_encoded_project_path = template.uri_encoded_project_path + + # extract a concrete commit for signing off what we actually downloaded + # this way we do the right thing even if the repository gets updated in the meantime + get_commits_response = Gitlab::HTTP.get("https://gitlab.com/api/v4/projects/#{uri_encoded_project_path}/repository/commits", + query: { page: 1, per_page: 1 } + ) + raise "Failed to retrieve latest commit for template '#{template.name}'" unless get_commits_response.success? + + commit_sha = get_commits_response.parsed_response.dig(0, 'id') + + project_archive_uri = "https://gitlab.com/api/v4/projects/#{uri_encoded_project_path}/repository/archive.tar.gz?sha=#{commit_sha}" + commit_message = <<~MSG + Initialized from '#{template.title}' project template + + Template repository: #{template.preview} + Commit SHA: #{commit_sha} + MSG + + Dir.mktmpdir do |tmpdir| + Dir.chdir(tmpdir) do + Gitlab::TaskHelpers.run_command!(['wget', project_archive_uri, '-O', 'archive.tar.gz']) + Gitlab::TaskHelpers.run_command!(['tar', 'xf', 'archive.tar.gz']) + extracted_project_basename = Dir['*/'].first + Dir.chdir(extracted_project_basename) do + Gitlab::TaskHelpers.run_command!(%w(git init)) + Gitlab::TaskHelpers.run_command!(%w(git add .)) + Gitlab::TaskHelpers.run_command!(['git', 'commit', '--author', 'GitLab <root@localhost>', '--message', commit_message]) + + # Hacky workaround to push to the project in a way that works with both GDK and the test environment + Gitlab::GitalyClient::StorageSettings.allow_disk_access do + Gitlab::TaskHelpers.run_command!(['git', 'remote', 'add', 'origin', "file://#{project.repository.raw.path}"]) + end + Gitlab::TaskHelpers.run_command!(['git', 'push', '-u', 'origin', 'master']) + end end - - if project.import_failed? - raise "Failed to import from #{project_params[:import_url]}" - end - - puts "Waiting for the import to finish" - - sleep(5) - project.reset end + project.reset + Projects::ImportExport::ExportService.new(project, admin).execute downloader.call(project.export_file, template.archive_path) diff --git a/lib/tasks/services.rake b/lib/tasks/services.rake index 56b81106c5f..4ec4fdd281f 100644 --- a/lib/tasks/services.rake +++ b/lib/tasks/services.rake @@ -86,7 +86,7 @@ namespace :services do doc_start = Time.now doc_path = File.join(Rails.root, 'doc', 'api', 'services.md') - result = ERB.new(services_template, 0, '>') + result = ERB.new(services_template, trim_mode: '>') .result(OpenStruct.new(services: services).instance_eval { binding }) File.open(doc_path, 'w') do |f| diff --git a/locale/gitlab.pot b/locale/gitlab.pot index d33c62031c4..afdfd620ca2 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2953,6 +2953,9 @@ msgstr "" msgid "Collapse sidebar" msgstr "" +msgid "Collector hostname" +msgstr "" + msgid "ComboSearch is not defined" msgstr "" @@ -3120,6 +3123,9 @@ msgstr "" msgid "Configure storage path settings." msgstr "" +msgid "Configure the %{link} integration." +msgstr "" + msgid "Configure the way a user creates a new account." msgstr "" @@ -3172,14 +3178,19 @@ msgid "ContainerRegistry|Quick Start" msgstr "" msgid "ContainerRegistry|Remove image" -msgstr "" +msgid_plural "ContainerRegistry|Remove images" +msgstr[0] "" +msgstr[1] "" -msgid "ContainerRegistry|Remove image and tags" +msgid "ContainerRegistry|Remove image(s) and tags" msgstr "" msgid "ContainerRegistry|Remove repository" msgstr "" +msgid "ContainerRegistry|Remove selected images" +msgstr "" + msgid "ContainerRegistry|Size" msgstr "" @@ -3201,6 +3212,9 @@ 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}" msgstr "" +msgid "ContainerRegistry|You are about to delete <b>%{count}</b> images. This will delete the images and all tags pointing to them." +msgstr "" + msgid "ContainerRegistry|You are about to delete the image <b>%{title}</b>. This will delete the image and all tags pointing to this image." msgstr "" @@ -3261,6 +3275,9 @@ msgstr "" msgid "ConvDev Index" msgstr "" +msgid "Cookie domain" +msgstr "" + msgid "Copied" msgstr "" @@ -4253,6 +4270,9 @@ msgstr "" msgid "Enable shared Runners" msgstr "" +msgid "Enable snowplow tracking" +msgstr "" + msgid "Enable two-factor authentication" msgstr "" @@ -5065,6 +5085,9 @@ msgstr "" msgid "For public projects, anyone can view pipelines and access job details (output logs and artifacts)" msgstr "" +msgid "Forgot your password?" +msgstr "" + msgid "Fork" msgstr "" @@ -5158,6 +5181,9 @@ msgstr "" msgid "Generate a default set of labels" msgstr "" +msgid "Generate link to chart" +msgstr "" + msgid "Generate new export" msgstr "" @@ -5215,6 +5241,9 @@ msgstr "" msgid "GitLab User" msgstr "" +msgid "GitLab member or Email address" +msgstr "" + msgid "GitLab project export" msgstr "" @@ -6419,9 +6448,15 @@ msgstr "" msgid "Last seen" msgstr "" +msgid "Last successful update" +msgstr "" + msgid "Last update" msgstr "" +msgid "Last update attempt" +msgstr "" + msgid "Last updated" msgstr "" @@ -6502,6 +6537,9 @@ msgid_plural "Limited to showing %d events at most" msgstr[0] "" msgstr[1] "" +msgid "Link copied to clipboard" +msgstr "" + msgid "Linked emails (%{email_count})" msgstr "" @@ -8114,6 +8152,9 @@ msgstr "" msgid "Please add a list to your board first" msgstr "" +msgid "Please check your email (%{email}) to verify that you own this address. Didn't receive it? %{resend_link}. Wrong email address? %{update_link}." +msgstr "" + msgid "Please choose a group URL with no special characters." msgstr "" @@ -9496,6 +9537,9 @@ msgstr "" msgid "Resend invite" msgstr "" +msgid "Resend it" +msgstr "" + msgid "Reset health check access token" msgstr "" @@ -9906,9 +9950,6 @@ msgstr "" msgid "Select branch/tag" msgstr "" -msgid "Select members to invite" -msgstr "" - msgid "Select merge moment" msgstr "" @@ -10280,6 +10321,9 @@ msgstr "" msgid "Similar issues" msgstr "" +msgid "Site ID" +msgstr "" + msgid "Size and domain settings for static websites" msgstr "" @@ -10310,6 +10354,9 @@ msgstr "" msgid "SnippetsEmptyState|They can be either public or private." msgstr "" +msgid "Snowplow" +msgstr "" + msgid "Some email servers do not support overriding the email sender name. Enable this option to include the name of the author of the issue, merge request or comment in the email body instead." msgstr "" @@ -12269,6 +12316,9 @@ msgstr "" msgid "Update failed" msgstr "" +msgid "Update it" +msgstr "" + msgid "Update now" msgstr "" @@ -12503,6 +12553,9 @@ msgstr "" msgid "Username is available." msgstr "" +msgid "Username or email" +msgstr "" + msgid "Users" msgstr "" diff --git a/package.json b/package.json index 803aebcb5fd..2b9a00d1cbd 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@babel/plugin-syntax-import-meta": "^7.2.0", "@babel/preset-env": "^7.4.4", "@gitlab/csslab": "^1.9.0", - "@gitlab/svgs": "^1.67.0", + "@gitlab/svgs": "^1.68.0", "@gitlab/ui": "5.15.0", "apollo-cache-inmemory": "^1.5.1", "apollo-client": "^2.5.1", diff --git a/qa/qa/page/main/login.rb b/qa/qa/page/main/login.rb index c2b0482d789..94245bbfcba 100644 --- a/qa/qa/page/main/login.rb +++ b/qa/qa/page/main/login.rb @@ -52,13 +52,11 @@ module QA raise NotImplementedError if Runtime::User.ldap_user? && user&.credentials_given? if Runtime::User.ldap_user? - sign_in_using_ldap_credentials + sign_in_using_ldap_credentials(user || Runtime::User) else sign_in_using_gitlab_credentials(user || Runtime::User) end end - - Page::Main::Menu.perform(&:has_personal_area?) end def sign_in_using_admin_credentials @@ -76,6 +74,25 @@ module QA Page::Main::Menu.perform(&:has_personal_area?) end + def sign_in_using_ldap_credentials(user) + # Log out if already logged in + Page::Main::Menu.perform do |menu| + menu.sign_out if menu.has_personal_area?(wait: 0) + end + + using_wait_time 0 do + set_initial_password_if_present + + switch_to_ldap_tab + + fill_element :username_field, user.ldap_username + fill_element :password_field, user.ldap_password + click_element :sign_in_button + end + + Page::Main::Menu.perform(&:has_personal_area?) + end + def self.path '/users/sign_in' end @@ -133,14 +150,6 @@ module QA private - def sign_in_using_ldap_credentials - switch_to_ldap_tab - - fill_element :username_field, Runtime::User.ldap_username - fill_element :password_field, Runtime::User.ldap_password - click_element :sign_in_button - end - def sign_in_using_gitlab_credentials(user) switch_to_sign_in_tab if has_sign_in_tab? switch_to_standard_tab if has_standard_tab? diff --git a/qa/qa/page/project/issue/new.rb b/qa/qa/page/project/issue/new.rb index 0d138417176..65c02801d67 100644 --- a/qa/qa/page/project/issue/new.rb +++ b/qa/qa/page/project/issue/new.rb @@ -6,7 +6,7 @@ module QA module Issue class New < Page::Base view 'app/views/shared/issuable/_form.html.haml' do - element :submit_issue_button, 'form.submit "Submit' # rubocop:disable QA/ElementWithPattern + element :issuable_create_button end view 'app/views/shared/issuable/form/_title.html.haml' do @@ -26,7 +26,7 @@ module QA end def create_new_issue - click_on 'Submit issue' + click_element :issuable_create_button, Page::Project::Issue::Show end end end diff --git a/qa/qa/page/project/issue/show.rb b/qa/qa/page/project/issue/show.rb index 507dccb52d0..45dad9bc0ae 100644 --- a/qa/qa/page/project/issue/show.rb +++ b/qa/qa/page/project/issue/show.rb @@ -14,7 +14,7 @@ module QA end view 'app/assets/javascripts/notes/components/discussion_filter.vue' do - element :discussion_filter + element :discussion_filter, required: true element :filter_options end diff --git a/qa/qa/page/project/menu.rb b/qa/qa/page/project/menu.rb index 3fe048f752a..838d59b59cb 100644 --- a/qa/qa/page/project/menu.rb +++ b/qa/qa/page/project/menu.rb @@ -5,7 +5,7 @@ module QA module Project class Menu < Page::Base include SubMenus::Common - + include SubMenus::Project include SubMenus::CiCd include SubMenus::Issues include SubMenus::Operations diff --git a/qa/qa/resource/merge_request.rb b/qa/qa/resource/merge_request.rb index 7969de726e4..45ab2396a04 100644 --- a/qa/qa/resource/merge_request.rb +++ b/qa/qa/resource/merge_request.rb @@ -5,7 +5,8 @@ require 'securerandom' module QA module Resource class MergeRequest < Base - attr_accessor :title, + attr_accessor :id, + :title, :description, :source_branch, :target_branch, @@ -74,6 +75,28 @@ module QA page.create_merge_request end end + + def fabricate_via_api! + populate(:target, :source) + super + end + + def api_get_path + "/projects/#{project.id}/merge_requests/#{id}" + end + + def api_post_path + "/projects/#{project.id}/merge_requests" + end + + def api_post_body + { + description: @description, + source_branch: @source_branch, + target_branch: @target_branch, + title: @title + } + end end end end diff --git a/qa/qa/scenario/test/sanity/selectors.rb b/qa/qa/scenario/test/sanity/selectors.rb index b4d70fc191a..632a0f5f2a9 100644 --- a/qa/qa/scenario/test/sanity/selectors.rb +++ b/qa/qa/scenario/test/sanity/selectors.rb @@ -7,11 +7,13 @@ module QA class Selectors < Scenario::Template include Scenario::Bootable - PAGES = [QA::Page].freeze + def pages + @pages ||= [QA::Page] + end def perform(*) - validators = PAGES.map do |pages| - Page::Validator.new(pages) + validators = pages.map do |page| + Page::Validator.new(page) end validators.flat_map(&:errors).tap do |errors| diff --git a/qa/qa/service/omnibus.rb b/qa/qa/service/omnibus.rb index b54fd5628f2..c5cddff56cd 100644 --- a/qa/qa/service/omnibus.rb +++ b/qa/qa/service/omnibus.rb @@ -11,11 +11,12 @@ module QA end def gitlab_ctl(command, input: nil) - if input.nil? - shell "docker exec #{@name} gitlab-ctl #{command}" - else - shell "docker exec #{@name} bash -c '#{input} | gitlab-ctl #{command}'" - end + docker_exec("gitlab-ctl #{command}", input: input) + end + + def docker_exec(command, input: nil) + command = "#{input} | #{command}" if input + shell "docker exec #{@name} bash -c '#{command}'" end end end diff --git a/qa/qa/specs/features/browser_ui/3_create/merge_request/view_merge_request_diff_patch_spec.rb b/qa/qa/specs/features/browser_ui/3_create/merge_request/view_merge_request_diff_patch_spec.rb index 9e48ee7ca2a..891cef6c420 100644 --- a/qa/qa/specs/features/browser_ui/3_create/merge_request/view_merge_request_diff_patch_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/merge_request/view_merge_request_diff_patch_spec.rb @@ -7,13 +7,18 @@ module QA Runtime::Browser.visit(:gitlab, Page::Main::Login) Page::Main::Login.perform(&:sign_in_using_credentials) - @merge_request = Resource::MergeRequest.fabricate! do |merge_request| + project = Resource::Project.fabricate_via_api! do |project| + project.name = 'project' + end + + @merge_request = Resource::MergeRequest.fabricate_via_api! do |merge_request| + merge_request.project = project merge_request.title = 'This is a merge request' - merge_request.description = 'For downloading patches and diffs' + merge_request.description = '... for downloading patches and diffs' end end - it 'user views merge request email patches' do + it 'views the merge request email patches' do @merge_request.visit! Page::MergeRequest::Show.perform(&:view_email_patches) @@ -22,7 +27,7 @@ module QA expect(page).to have_content('diff --git a/added_file.txt b/added_file.txt') end - it 'user views merge request plain diff' do + it 'views the merge request plain diff' do @merge_request.visit! Page::MergeRequest::Show.perform(&:view_plain_diff) diff --git a/qa/qa/specs/features/browser_ui/3_create/web_ide/add_file_template_spec.rb b/qa/qa/specs/features/browser_ui/3_create/web_ide/add_file_template_spec.rb index c09c65a57a5..9ff7919f199 100644 --- a/qa/qa/specs/features/browser_ui/3_create/web_ide/add_file_template_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/web_ide/add_file_template_spec.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true module QA - context 'Create' do + context 'Create', :quarantine do + # Failure issue: https://gitlab.com/gitlab-org/quality/nightly/issues/127 describe 'Web IDE file templates' do include Runtime::Fixtures diff --git a/qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb b/qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb index 2fe4e4d9d1f..f6411d8c5ad 100644 --- a/qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb +++ b/qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb @@ -3,36 +3,29 @@ require 'digest/sha1' module QA - # Failure issue: https://gitlab.com/gitlab-org/quality/nightly/issues/70 - context 'Release', :docker, :quarantine do + context 'Release', :docker do describe 'Git clone using a deploy key' do - def login + before do Runtime::Browser.visit(:gitlab, Page::Main::Login) Page::Main::Login.perform(&:sign_in_using_credentials) - end - - before(:all) do - login @runner_name = "qa-runner-#{Time.now.to_i}" - @project = Resource::Project.fabricate! do |resource| + @project = Resource::Project.fabricate_via_api! do |resource| resource.name = 'deploy-key-clone-project' end @repository_location = @project.repository_ssh_location - Resource::Runner.fabricate! do |resource| + Resource::Runner.fabricate_via_browser_ui! do |resource| resource.project = @project resource.name = @runner_name resource.tags = %w[qa docker] resource.image = 'gitlab/gitlab-runner:ubuntu' end - - Page::Main::Menu.perform(&:sign_out) end - after(:all) do + after do Service::Runner.new(@runner_name).remove! end @@ -46,9 +39,7 @@ module QA it "user sets up a deploy key with #{key_class}(#{bits}) to clone code using pipelines" do key = key_class.new(*bits) - login - - Resource::DeployKey.fabricate! do |resource| + Resource::DeployKey.fabricate_via_browser_ui! do |resource| resource.project = @project resource.title = "deploy key #{key.name}(#{key.bits})" resource.key = key.public_key @@ -56,7 +47,7 @@ module QA deploy_key_name = "DEPLOY_KEY_#{key.name}_#{key.bits}" - Resource::CiVariable.fabricate! do |resource| + Resource::CiVariable.fabricate_via_browser_ui! do |resource| resource.project = @project resource.key = deploy_key_name resource.value = key.private_key diff --git a/qa/qa/vendor/saml_idp/page/login.rb b/qa/qa/vendor/saml_idp/page/login.rb index 9c1f9904a7a..1b8c926532a 100644 --- a/qa/qa/vendor/saml_idp/page/login.rb +++ b/qa/qa/vendor/saml_idp/page/login.rb @@ -12,6 +12,14 @@ module QA fill_in 'password', with: 'user1pass' click_on 'Login' end + + def login_if_required + login if login_required? + end + + def login_required? + page.has_text?('Enter your username and password') + end end end end diff --git a/qa/spec/resource/repository/push_spec.rb b/qa/spec/resource/repository/push_spec.rb index bf3ebce0cfe..2f9e4958ae1 100644 --- a/qa/spec/resource/repository/push_spec.rb +++ b/qa/spec/resource/repository/push_spec.rb @@ -19,7 +19,11 @@ describe QA::Resource::Repository::Push do expect { subject.files = [] }.to raise_error(ArgumentError) end - it 'does not raise if files is an array' do + it 'raises an error if files is not an array of hashes with :name and :content keys' do + expect { subject.files = [{ foo: 'foo' }] }.to raise_error(ArgumentError) + end + + it 'does not raise if files is an array of hashes with :name and :content keys' do expect { subject.files = files }.not_to raise_error end end diff --git a/qa/spec/runtime/env_spec.rb b/qa/spec/runtime/env_spec.rb index caf96a213e1..340831aa06d 100644 --- a/qa/spec/runtime/env_spec.rb +++ b/qa/spec/runtime/env_spec.rb @@ -192,6 +192,30 @@ describe QA::Runtime::Env do end end + describe '.knapsack?' do + it 'returns true if KNAPSACK_GENERATE_REPORT is defined' do + stub_env('KNAPSACK_GENERATE_REPORT', 'true') + + expect(described_class.knapsack?).to be_truthy + end + + it 'returns true if KNAPSACK_REPORT_PATH is defined' do + stub_env('KNAPSACK_REPORT_PATH', '/a/path') + + expect(described_class.knapsack?).to be_truthy + end + + it 'returns true if KNAPSACK_TEST_FILE_PATTERN is defined' do + stub_env('KNAPSACK_TEST_FILE_PATTERN', '/a/**/pattern') + + expect(described_class.knapsack?).to be_truthy + end + + it 'returns false if neither KNAPSACK_GENERATE_REPORT nor KNAPSACK_REPORT_PATH nor KNAPSACK_TEST_FILE_PATTERN are defined' do + expect(described_class.knapsack?).to be_falsey + end + end + describe '.require_github_access_token!' do it 'raises ArgumentError if GITHUB_ACCESS_TOKEN is not defined' do stub_env('GITHUB_ACCESS_TOKEN', nil) diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 84bbbac39b0..0b3833e6515 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -641,24 +641,32 @@ describe ApplicationController do end end - it 'does not set a custom header' do + it 'sets a custom header' do get :index, format: :json - expect(response.headers['X-GitLab-Custom-Error']).to be_nil + expect(response.headers['X-GitLab-Custom-Error']).to eq '1' end - end - context 'given a json response for an html request' do - controller do - def index - render json: {}, status: :unprocessable_entity + context 'for html request' do + it 'sets a custom header' do + get :index + + expect(response.headers['X-GitLab-Custom-Error']).to eq '1' end end - it 'does not set a custom header' do - get :index + context 'for 200 response' do + controller do + def index + render json: {}, status: :ok + end + end - expect(response.headers['X-GitLab-Custom-Error']).to be_nil + it 'does not set a custom header' do + get :index, format: :json + + expect(response.headers['X-GitLab-Custom-Error']).to be_nil + end end end end diff --git a/spec/controllers/concerns/confirm_email_warning_spec.rb b/spec/controllers/concerns/confirm_email_warning_spec.rb new file mode 100644 index 00000000000..0c598a360af --- /dev/null +++ b/spec/controllers/concerns/confirm_email_warning_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ConfirmEmailWarning do + before do + stub_feature_flags(soft_email_confirmation: true) + allow(User).to receive(:allow_unconfirmed_access_for).and_return 2.days + end + + controller(ApplicationController) do + # `described_class` is not available in this context + include ConfirmEmailWarning # rubocop:disable RSpec/DescribedClass + + def index + head :ok + end + end + + RSpec::Matchers.define :set_confirm_warning_for do |email| + match do |response| + expect(response).to set_flash.now[:warning].to include("Please check your email (#{email}) to verify that you own this address.") + end + end + + describe 'confirm email flash warning' do + context 'when not signed in' do + let(:user) { create(:user, confirmed_at: nil) } + + before do + get :index + end + + it { is_expected.not_to set_confirm_warning_for(user.email) } + end + + context 'when signed in' do + before do + sign_in(user) + end + + context 'with a confirmed user' do + let(:user) { create(:user) } + + before do + get :index + end + + it { is_expected.not_to set_confirm_warning_for(user.email) } + end + + context 'with an unconfirmed user' do + let(:user) { create(:user, confirmed_at: nil) } + + context 'when executing a peek request' do + before do + request.path = '/-/peek' + get :index + end + + it { is_expected.not_to set_confirm_warning_for(user.email) } + end + + context 'when executing a json request' do + before do + get :index, format: :json + end + + it { is_expected.not_to set_confirm_warning_for(user.email) } + end + + context 'when executing a post request' do + before do + post :index + end + + it { is_expected.not_to set_confirm_warning_for(user.email) } + end + + context 'when executing a get request' do + before do + get :index + end + + context 'with an unconfirmed email address present' do + let(:user) { create(:user, confirmed_at: nil, unconfirmed_email: 'unconfirmed@gitlab.com') } + + it { is_expected.to set_confirm_warning_for(user.unconfirmed_email) } + end + + context 'without an unconfirmed email address present' do + it { is_expected.to set_confirm_warning_for(user.email) } + end + end + end + end + end +end diff --git a/spec/controllers/projects/cycle_analytics_controller_spec.rb b/spec/controllers/projects/cycle_analytics_controller_spec.rb index 2dc97e18113..5e6ceef2517 100644 --- a/spec/controllers/projects/cycle_analytics_controller_spec.rb +++ b/spec/controllers/projects/cycle_analytics_controller_spec.rb @@ -11,6 +11,20 @@ describe Projects::CycleAnalyticsController do project.add_maintainer(user) end + context "counting page views for 'show'" do + it 'increases the counter' do + expect(Gitlab::UsageDataCounters::CycleAnalyticsCounter).to receive(:count).with(:views) + + get(:show, + params: { + namespace_id: project.namespace, + project_id: project + }) + + expect(response).to be_success + end + end + describe 'cycle analytics not set up flag' do context 'with no data' do it 'is true' do diff --git a/spec/controllers/projects/git_http_controller_spec.rb b/spec/controllers/projects/git_http_controller_spec.rb index bf099e8deeb..88fa2236e33 100644 --- a/spec/controllers/projects/git_http_controller_spec.rb +++ b/spec/controllers/projects/git_http_controller_spec.rb @@ -12,4 +12,15 @@ describe Projects::GitHttpController do expect(response.status).to eq(403) end end + + describe 'GET #info_refs' do + it 'returns 401 for unauthenticated requests to public repositories when http protocol is disabled' do + stub_application_setting(enabled_git_access_protocol: 'ssh') + project = create(:project, :public, :repository) + + get :info_refs, params: { service: 'git-upload-pack', namespace_id: project.namespace.to_param, project_id: project.path + '.git' } + + expect(response.status).to eq(401) + end + end end diff --git a/spec/controllers/projects/registry/tags_controller_spec.rb b/spec/controllers/projects/registry/tags_controller_spec.rb index ff35139ae2e..c6e063d8229 100644 --- a/spec/controllers/projects/registry/tags_controller_spec.rb +++ b/spec/controllers/projects/registry/tags_controller_spec.rb @@ -113,4 +113,37 @@ describe Projects::Registry::TagsController do format: :json end end + + describe 'POST bulk_destroy' do + context 'when user has access to registry' do + before do + project.add_developer(user) + end + + context 'when there is matching tag present' do + before do + stub_container_registry_tags(repository: repository.path, tags: %w[rc1 test.]) + end + + it 'makes it possible to delete tags in bulk' do + allow_any_instance_of(ContainerRegistry::Tag).to receive(:delete) { |*args| ContainerRegistry::Tag.delete(*args) } + expect(ContainerRegistry::Tag).to receive(:delete).exactly(2).times + + bulk_destroy_tags(['rc1', 'test.']) + end + end + end + + private + + def bulk_destroy_tags(names) + post :bulk_destroy, params: { + namespace_id: project.namespace, + project_id: project, + repository_id: repository, + ids: names + }, + format: :json + end + end end diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb index faf3c990cb2..fed4fc810f2 100644 --- a/spec/controllers/registrations_controller_spec.rb +++ b/spec/controllers/registrations_controller_spec.rb @@ -5,6 +5,10 @@ require 'spec_helper' describe RegistrationsController do include TermsHelper + before do + stub_feature_flags(invisible_captcha: false) + end + describe '#create' do let(:base_user_params) { { name: 'new_user', username: 'new_username', email: 'new@user.com', password: 'Any_password' } } let(:user_params) { { user: base_user_params } } @@ -26,13 +30,36 @@ describe RegistrationsController do end context 'when send_user_confirmation_email is true' do - it 'does not authenticate user and sends confirmation email' do + before do stub_application_setting(send_user_confirmation_email: true) + end + + context 'when soft email confirmation is not enabled' do + before do + stub_feature_flags(soft_email_confirmation: false) + allow(User).to receive(:allow_unconfirmed_access_for).and_return 0 + end - post(:create, params: user_params) + it 'does not authenticate the user and sends a confirmation email' do + post(:create, params: user_params) - expect(ActionMailer::Base.deliveries.last.to.first).to eq(user_params[:user][:email]) - expect(subject.current_user).to be_nil + expect(ActionMailer::Base.deliveries.last.to.first).to eq(user_params[:user][:email]) + expect(subject.current_user).to be_nil + end + end + + context 'when soft email confirmation is enabled' do + before do + stub_feature_flags(soft_email_confirmation: true) + allow(User).to receive(:allow_unconfirmed_access_for).and_return 2.days + end + + it 'authenticates the user and sends a confirmation email' do + post(:create, params: user_params) + + expect(ActionMailer::Base.deliveries.last.to.first).to eq(user_params[:user][:email]) + expect(response).to redirect_to(dashboard_projects_path) + end end end @@ -88,6 +115,88 @@ describe RegistrationsController do end end + context 'when invisible captcha is enabled' do + before do + stub_feature_flags(invisible_captcha: true) + InvisibleCaptcha.timestamp_threshold = treshold + end + + let(:treshold) { 4 } + let(:session_params) { { invisible_captcha_timestamp: form_rendered_time.iso8601 } } + let(:form_rendered_time) { Time.current } + let(:submit_time) { form_rendered_time + treshold } + let(:auth_log_attributes) do + { + message: auth_log_message, + env: :invisible_captcha_signup_bot_detected, + ip: '0.0.0.0', + request_method: 'POST', + fullpath: '/users' + } + end + + describe 'the honeypot has not been filled and the signup form has not been submitted too quickly' do + it 'creates an account' do + travel_to(submit_time) do + expect { post(:create, params: user_params, session: session_params) }.to change(User, :count).by(1) + end + end + end + + describe 'honeypot spam detection' do + let(:user_params) { super().merge(firstname: 'Roy', lastname: 'Batty') } + let(:auth_log_message) { 'Invisible_Captcha_Honeypot_Request' } + + it 'logs the request, refuses to create an account and renders an empty body' do + travel_to(submit_time) do + expect(Gitlab::Metrics).to receive(:counter) + .with(:bot_blocked_by_invisible_captcha_honeypot, 'Counter of blocked sign up attempts with filled honeypot') + .and_call_original + expect(Gitlab::AuthLogger).to receive(:error).with(auth_log_attributes).once + expect { post(:create, params: user_params, session: session_params) }.not_to change(User, :count) + expect(response).to have_gitlab_http_status(200) + expect(response.body).to be_empty + end + end + end + + describe 'timestamp spam detection' do + let(:auth_log_message) { 'Invisible_Captcha_Timestamp_Request' } + + context 'the sign up form has been submitted without the invisible_captcha_timestamp parameter' do + let(:session_params) { nil } + + it 'logs the request, refuses to create an account and displays a flash alert' do + travel_to(submit_time) do + expect(Gitlab::Metrics).to receive(:counter) + .with(:bot_blocked_by_invisible_captcha_timestamp, 'Counter of blocked sign up attempts with invalid timestamp') + .and_call_original + expect(Gitlab::AuthLogger).to receive(:error).with(auth_log_attributes).once + expect { post(:create, params: user_params, session: session_params) }.not_to change(User, :count) + expect(response).to redirect_to(new_user_session_path) + expect(flash[:alert]).to include 'That was a bit too quick! Please resubmit.' + end + end + end + + context 'the sign up form has been submitted too quickly' do + let(:submit_time) { form_rendered_time } + + it 'logs the request, refuses to create an account and displays a flash alert' do + travel_to(submit_time) do + expect(Gitlab::Metrics).to receive(:counter) + .with(:bot_blocked_by_invisible_captcha_timestamp, 'Counter of blocked sign up attempts with invalid timestamp') + .and_call_original + expect(Gitlab::AuthLogger).to receive(:error).with(auth_log_attributes).once + expect { post(:create, params: user_params, session: session_params) }.not_to change(User, :count) + expect(response).to redirect_to(new_user_session_path) + expect(flash[:alert]).to include 'That was a bit too quick! Please resubmit.' + end + end + end + end + end + context 'when terms are enforced' do before do enforce_terms diff --git a/spec/factories/ci/bridge.rb b/spec/factories/ci/bridge.rb index 6491b9dca19..b1b714277e4 100644 --- a/spec/factories/ci/bridge.rb +++ b/spec/factories/ci/bridge.rb @@ -8,7 +8,7 @@ FactoryBot.define do ref 'master' tag false created_at 'Di 29. Okt 09:50:00 CET 2013' - status :success + status :created pipeline factory: :ci_pipeline @@ -17,6 +17,7 @@ FactoryBot.define do end transient { downstream nil } + transient { upstream nil } after(:build) do |bridge, evaluator| bridge.project ||= bridge.pipeline.project @@ -26,6 +27,12 @@ FactoryBot.define do trigger: { project: evaluator.downstream.full_path } ) end + + if evaluator.upstream.present? + bridge.options = bridge.options.to_h.merge( + bridge_needs: { pipeline: evaluator.upstream.full_path } + ) + end end end end diff --git a/spec/factories/services.rb b/spec/factories/services.rb index f3e662ad4f5..b2d6ada91fa 100644 --- a/spec/factories/services.rb +++ b/spec/factories/services.rb @@ -16,6 +16,19 @@ FactoryBot.define do ) end + factory :emails_on_push_service do + project + type 'EmailsOnPushService' + active true + push_events true + tag_push_events true + properties( + recipients: 'test@example.com', + disable_diffs: true, + send_from_committer_email: true + ) + end + factory :mock_deployment_service do project type 'MockDeploymentService' diff --git a/spec/features/boards/multiple_boards_spec.rb b/spec/features/boards/multiple_boards_spec.rb new file mode 100644 index 00000000000..9a2b7a80498 --- /dev/null +++ b/spec/features/boards/multiple_boards_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Multiple Issue Boards', :js do + set(:user) { create(:user) } + set(:project) { create(:project, :public) } + set(:planning) { create(:label, project: project, name: 'Planning') } + set(:board) { create(:board, name: 'board1', project: project) } + set(:board2) { create(:board, name: 'board2', project: project) } + let(:parent) { project } + let(:boards_path) { project_boards_path(project) } + + it_behaves_like 'multiple issue boards' +end diff --git a/spec/features/container_registry_spec.rb b/spec/features/container_registry_spec.rb index 89dece97a35..aefdc4d6d4f 100644 --- a/spec/features/container_registry_spec.rb +++ b/spec/features/container_registry_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe "Container Registry", :js do +describe 'Container Registry', :js do let(:user) { create(:user) } let(:project) { create(:project) } @@ -40,8 +40,7 @@ describe "Container Registry", :js do it 'user removes entire container repository' do visit_container_registry - expect_any_instance_of(ContainerRepository) - .to receive(:delete_tags!).and_return(true) + expect_any_instance_of(ContainerRepository).to receive(:delete_tags!).and_return(true) click_on(class: 'js-remove-repo') expect(find('.modal .modal-title')).to have_content 'Remove repository' @@ -54,10 +53,9 @@ describe "Container Registry", :js do find('.js-toggle-repo').click wait_for_requests - expect_any_instance_of(ContainerRegistry::Tag) - .to receive(:delete).and_return(true) + expect_any_instance_of(ContainerRegistry::Tag).to receive(:delete).and_return(true) - click_on(class: 'js-delete-registry') + click_on(class: 'js-delete-registry-row', visible: false) expect(find('.modal .modal-title')).to have_content 'Remove image' find('.modal .modal-footer .btn-danger').click end diff --git a/spec/features/invites_spec.rb b/spec/features/invites_spec.rb index 855cf22642e..1e054a7b358 100644 --- a/spec/features/invites_spec.rb +++ b/spec/features/invites_spec.rb @@ -10,17 +10,17 @@ describe 'Invites' do let(:group_invite) { group.group_members.invite.last } before do + stub_feature_flags(invisible_captcha: false) project.add_maintainer(owner) group.add_user(owner, Gitlab::Access::OWNER) group.add_developer('user@example.com', owner) group_invite.generate_invite_token! end - def confirm_email_and_sign_in(new_user) + def confirm_email(new_user) new_user_token = User.find_by_email(new_user.email).confirmation_token visit user_confirmation_path(confirmation_token: new_user_token) - fill_in_sign_in_form(new_user) end def fill_in_sign_up_form(new_user) @@ -154,17 +154,41 @@ describe 'Invites' do context 'email confirmation enabled' do let(:send_email_confirmation) { true } - it 'signs up and redirects to root page with all the project/groups invitation automatically accepted' do - fill_in_sign_up_form(new_user) - confirm_email_and_sign_in(new_user) + context 'when soft email confirmation is not enabled' do + before do + # stub_feature_flags(soft_email_confirmation: false) + allow(User).to receive(:allow_unconfirmed_access_for).and_return 0 + end - expect(current_path).to eq(root_path) - expect(page).to have_content(project.full_name) - visit group_path(group) - expect(page).to have_content(group.full_name) + it 'signs up and redirects to root page with all the project/groups invitation automatically accepted' do + fill_in_sign_up_form(new_user) + confirm_email(new_user) + fill_in_sign_in_form(new_user) + + expect(current_path).to eq(root_path) + expect(page).to have_content(project.full_name) + visit group_path(group) + expect(page).to have_content(group.full_name) + end end - it "doesn't accept invitations until the user confirm his email" do + context 'when soft email confirmation is enabled' do + before do + allow(User).to receive(:allow_unconfirmed_access_for).and_return 2.days + end + + it 'signs up and redirects to root page with all the project/groups invitation automatically accepted' do + fill_in_sign_up_form(new_user) + confirm_email(new_user) + + expect(current_path).to eq(root_path) + expect(page).to have_content(project.full_name) + visit group_path(group) + expect(page).to have_content(group.full_name) + end + end + + it "doesn't accept invitations until the user confirms his email" do fill_in_sign_up_form(new_user) sign_in(owner) @@ -175,11 +199,32 @@ describe 'Invites' do context 'the user sign-up using a different email address' do let(:invite_email) { build_stubbed(:user).email } - it 'signs up and redirects to the invitation page' do - fill_in_sign_up_form(new_user) - confirm_email_and_sign_in(new_user) + context 'when soft email confirmation is not enabled' do + before do + stub_feature_flags(soft_email_confirmation: false) + allow(User).to receive(:allow_unconfirmed_access_for).and_return 0 + end - expect(current_path).to eq(invite_path(group_invite.raw_invite_token)) + it 'signs up and redirects to the invitation page' do + fill_in_sign_up_form(new_user) + confirm_email(new_user) + fill_in_sign_in_form(new_user) + + expect(current_path).to eq(invite_path(group_invite.raw_invite_token)) + end + end + + context 'when soft email confirmation is enabled' do + before do + stub_feature_flags(soft_email_confirmation: true) + allow(User).to receive(:allow_unconfirmed_access_for).and_return 2.days + end + + it 'signs up and redirects to the invitation page' do + fill_in_sign_up_form(new_user) + + expect(current_path).to eq(invite_path(group_invite.raw_invite_token)) + end end end end diff --git a/spec/features/markdown/metrics_spec.rb b/spec/features/markdown/metrics_spec.rb index aa53ac50c78..4de67cfcdbe 100644 --- a/spec/features/markdown/metrics_spec.rb +++ b/spec/features/markdown/metrics_spec.rb @@ -26,13 +26,31 @@ describe 'Metrics rendering', :js, :use_clean_rails_memory_store_caching do restore_host end - context 'with deployments and related deployable present' do - it 'shows embedded metrics' do + it 'shows embedded metrics' do + visit project_issue_path(project, issue) + + expect(page).to have_css('div.prometheus-graph') + expect(page).to have_text('Memory Usage (Total)') + expect(page).to have_text('Core Usage (Total)') + end + + context 'when dashboard params are in included the url' do + let(:metrics_url) { metrics_project_environment_url(project, environment, **chart_params) } + + let(:chart_params) do + { + group: 'System metrics (Kubernetes)', + title: 'Memory Usage (Pod average)', + y_label: 'Memory Used per Pod (MB)' + } + end + + it 'shows embedded metrics for the specifiec chart' do visit project_issue_path(project, issue) expect(page).to have_css('div.prometheus-graph') - expect(page).to have_text('Memory Usage (Total)') - expect(page).to have_text('Core Usage (Total)') + expect(page).to have_text(chart_params[:title]) + expect(page).to have_text(chart_params[:y_label]) end end diff --git a/spec/features/profiles/user_visits_profile_preferences_page_spec.rb b/spec/features/profiles/user_visits_profile_preferences_page_spec.rb index 5e52c82a234..4dbdea02e27 100644 --- a/spec/features/profiles/user_visits_profile_preferences_page_spec.rb +++ b/spec/features/profiles/user_visits_profile_preferences_page_spec.rb @@ -38,7 +38,7 @@ describe 'User visits the profile preferences page' do describe 'User changes their default dashboard', :js do it 'creates a flash message' do - select 'Starred Projects', from: 'user_dashboard' + select2('stars', from: '#user_dashboard') click_button 'Save' wait_for_requests @@ -47,7 +47,7 @@ describe 'User visits the profile preferences page' do end it 'updates their preference' do - select 'Starred Projects', from: 'user_dashboard' + select2('stars', from: '#user_dashboard') click_button 'Save' wait_for_requests diff --git a/spec/features/projects/files/user_browses_a_tree_with_a_folder_containing_only_a_folder_spec.rb b/spec/features/projects/files/user_browses_a_tree_with_a_folder_containing_only_a_folder_spec.rb index c19e46da913..6bd569e5ee2 100644 --- a/spec/features/projects/files/user_browses_a_tree_with_a_folder_containing_only_a_folder_spec.rb +++ b/spec/features/projects/files/user_browses_a_tree_with_a_folder_containing_only_a_folder_spec.rb @@ -3,18 +3,15 @@ require 'spec_helper' # This is a regression test for https://gitlab.com/gitlab-org/gitlab-ce/issues/37569 -# Quarantine: https://gitlab.com/gitlab-org/gitlab-ce/issues/65329 -describe 'Projects > Files > User browses a tree with a folder containing only a folder', :quarantine do +describe 'Projects > Files > User browses a tree with a folder containing only a folder', :js do let(:project) { create(:project, :empty_repo) } let(:user) { project.owner } before do - # We need to disable the tree.flat_path provided by Gitaly to reproduce the issue - allow(Gitlab::GitalyClient).to receive(:feature_enabled?).and_return(false) - project.repository.create_dir(user, 'foo/bar', branch_name: 'master', message: 'Add the foo/bar folder') sign_in(user) visit(project_tree_path(project, project.repository.root_ref)) + wait_for_requests end it 'shows the nested folder on a single row' do diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb index dac8c8e7a29..8e4db2ca840 100644 --- a/spec/features/users/login_spec.rb +++ b/spec/features/users/login_spec.rb @@ -95,6 +95,42 @@ describe 'Login' do end end + describe 'with an unconfirmed email address' do + let!(:user) { create(:user, confirmed_at: nil) } + let(:grace_period) { 2.days } + + before do + stub_application_setting(send_user_confirmation_email: true) + allow(User).to receive(:allow_unconfirmed_access_for).and_return grace_period + end + + context 'within the grace period' do + it 'allows to login' do + expect(authentication_metrics).to increment(:user_authenticated_counter) + + gitlab_sign_in(user) + + expect(page).not_to have_content('You have to confirm your email address before continuing.') + expect(page).not_to have_link('Resend confirmation email', href: new_user_confirmation_path) + end + end + + context 'when the confirmation grace period is expired' do + it 'prevents the user from logging in and renders a resend confirmation email link' do + travel_to((grace_period + 1.day).from_now) do + expect(authentication_metrics) + .to increment(:user_unauthenticated_counter) + .and increment(:user_session_destroyed_counter).twice + + gitlab_sign_in(user) + + expect(page).to have_content('You have to confirm your email address before continuing.') + expect(page).to have_link('Resend confirmation email', href: new_user_confirmation_path) + end + end + end + end + describe 'with the ghost user' do it 'disallows login' do expect(authentication_metrics) @@ -745,4 +781,39 @@ describe 'Login' do end end end + + context 'when sending confirmation email and not yet confirmed' do + let!(:user) { create(:user, confirmed_at: nil) } + let(:grace_period) { 2.days } + + before do + stub_application_setting(send_user_confirmation_email: true) + stub_feature_flags(soft_email_confirmation: true) + allow(User).to receive(:allow_unconfirmed_access_for).and_return grace_period + end + + it 'allows login and shows a flash warning to confirm the email address' do + expect(authentication_metrics).to increment(:user_authenticated_counter) + + gitlab_sign_in(user) + + expect(current_path).to eq root_path + expect(page).to have_content("Please check your email (#{user.email}) to verify that you own this address.") + end + + context "when not having confirmed within Devise's allow_unconfirmed_access_for time" do + it 'does not allow login and shows a flash alert to confirm the email address' do + travel_to((grace_period + 1.day).from_now) do + expect(authentication_metrics) + .to increment(:user_unauthenticated_counter) + .and increment(:user_session_destroyed_counter).twice + + gitlab_sign_in(user) + + expect(current_path).to eq new_user_session_path + expect(page).to have_content('You have to confirm your email address before continuing.') + end + end + end + end end diff --git a/spec/features/users/signup_spec.rb b/spec/features/users/signup_spec.rb index f5897bffaf0..fb927a9ca3b 100644 --- a/spec/features/users/signup_spec.rb +++ b/spec/features/users/signup_spec.rb @@ -5,6 +5,10 @@ require 'spec_helper' describe 'Signup' do include TermsHelper + before do + stub_feature_flags(invisible_captcha: false) + end + let(:new_user) { build_stubbed(:user) } describe 'username validation', :js do @@ -162,24 +166,51 @@ describe 'Signup' do end context 'with no errors' do - context "when sending confirmation email" do + context 'when sending confirmation email' do before do stub_application_setting(send_user_confirmation_email: true) end - it 'creates the user account and sends a confirmation email' do - visit root_path + context 'when soft email confirmation is not enabled' do + before do + stub_feature_flags(soft_email_confirmation: false) + end - fill_in 'new_user_name', with: new_user.name - fill_in 'new_user_username', with: new_user.username - fill_in 'new_user_email', with: new_user.email - fill_in 'new_user_email_confirmation', with: new_user.email - fill_in 'new_user_password', with: new_user.password + it 'creates the user account and sends a confirmation email' do + visit root_path + + fill_in 'new_user_name', with: new_user.name + fill_in 'new_user_username', with: new_user.username + fill_in 'new_user_email', with: new_user.email + fill_in 'new_user_email_confirmation', with: new_user.email + fill_in 'new_user_password', with: new_user.password + + expect { click_button 'Register' }.to change { User.count }.by(1) + + expect(current_path).to eq users_almost_there_path + expect(page).to have_content('Please check your email to confirm your account') + end + end + + context 'when soft email confirmation is enabled' do + before do + stub_feature_flags(soft_email_confirmation: true) + end + + it 'creates the user account and sends a confirmation email' do + visit root_path + + fill_in 'new_user_name', with: new_user.name + fill_in 'new_user_username', with: new_user.username + fill_in 'new_user_email', with: new_user.email + fill_in 'new_user_email_confirmation', with: new_user.email + fill_in 'new_user_password', with: new_user.password - expect { click_button 'Register' }.to change { User.count }.by(1) + expect { click_button 'Register' }.to change { User.count }.by(1) - expect(current_path).to eq users_almost_there_path - expect(page).to have_content("Please check your email to confirm your account") + expect(current_path).to eq dashboard_projects_path + expect(page).to have_content("Please check your email (#{new_user.email}) to verify that you own this address.") + end end end diff --git a/spec/fixtures/api/schemas/deployment.json b/spec/fixtures/api/schemas/deployment.json index 0828f113495..9216ad0060b 100644 --- a/spec/fixtures/api/schemas/deployment.json +++ b/spec/fixtures/api/schemas/deployment.json @@ -3,6 +3,7 @@ "required": [ "sha", "created_at", + "finished_at", "iid", "tag", "last?", @@ -11,6 +12,7 @@ ], "properties": { "created_at": { "type": "string" }, + "finished_at": { "type": ["string", "null"] }, "id": { "type": "integer" }, "iid": { "type": "integer" }, "last?": { "type": "boolean" }, diff --git a/spec/frontend/notes/components/discussion_keyboard_navigator_spec.js b/spec/frontend/notes/components/discussion_keyboard_navigator_spec.js index 6d50713999d..8881bedf3cc 100644 --- a/spec/frontend/notes/components/discussion_keyboard_navigator_spec.js +++ b/spec/frontend/notes/components/discussion_keyboard_navigator_spec.js @@ -74,4 +74,31 @@ describe('notes/components/discussion_keyboard_navigator', () => { expect(wrapper.vm.currentDiscussionId).toEqual(expectedPrevId); }); }); + + describe('on destroy', () => { + beforeEach(() => { + jest.spyOn(Mousetrap, 'unbind'); + + createComponent(); + + wrapper.destroy(); + }); + + it('unbinds keys', () => { + expect(Mousetrap.unbind).toHaveBeenCalledWith('n'); + expect(Mousetrap.unbind).toHaveBeenCalledWith('p'); + }); + + it('does not call jumpToNextDiscussion when pressing `n`', () => { + Mousetrap.trigger('n'); + + expect(wrapper.vm.jumpToDiscussion).not.toHaveBeenCalled(); + }); + + it('does not call jumpToNextDiscussion when pressing `p`', () => { + Mousetrap.trigger('p'); + + expect(wrapper.vm.jumpToDiscussion).not.toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/changed_file_icon_spec.js b/spec/frontend/vue_shared/components/changed_file_icon_spec.js new file mode 100644 index 00000000000..806602877ef --- /dev/null +++ b/spec/frontend/vue_shared/components/changed_file_icon_spec.js @@ -0,0 +1,123 @@ +import { shallowMount } from '@vue/test-utils'; +import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; + +const changedFile = () => ({ changed: true }); +const stagedFile = () => ({ changed: false, staged: true }); +const changedAndStagedFile = () => ({ changed: true, staged: true }); +const newFile = () => ({ changed: true, tempFile: true }); +const unchangedFile = () => ({ changed: false, tempFile: false, staged: false, deleted: false }); + +describe('Changed file icon', () => { + let wrapper; + + const factory = (props = {}) => { + wrapper = shallowMount(ChangedFileIcon, { + propsData: { + file: changedFile(), + showTooltip: true, + ...props, + }, + sync: false, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findIcon = () => wrapper.find(Icon); + const findIconName = () => findIcon().props('name'); + const findIconClasses = () => + findIcon() + .props('cssClasses') + .split(' '); + const findTooltipText = () => wrapper.attributes('data-original-title'); + + it('with isCentered true, adds center class', () => { + factory({ + isCentered: true, + }); + + expect(wrapper.classes('ml-auto')).toBe(true); + }); + + it('with isCentered false, does not center', () => { + factory({ + isCentered: false, + }); + + expect(wrapper.classes('ml-auto')).toBe(false); + }); + + it('with showTooltip false, does not show tooltip', () => { + factory({ + showTooltip: false, + }); + + expect(findTooltipText()).toBeFalsy(); + }); + + describe.each` + file | iconName | tooltipText | desc + ${changedFile()} | ${'file-modified'} | ${'Unstaged modification'} | ${'with file changed'} + ${stagedFile()} | ${'file-modified-solid'} | ${'Staged modification'} | ${'with file staged'} + ${changedAndStagedFile()} | ${'file-modified'} | ${'Unstaged and staged modification'} | ${'with file changed and staged'} + ${newFile()} | ${'file-addition'} | ${'Unstaged addition'} | ${'with file new'} + `('$desc', ({ file, iconName, tooltipText }) => { + beforeEach(() => { + factory({ file }); + }); + + it('renders icon', () => { + expect(findIconName()).toBe(iconName); + expect(findIconClasses()).toContain(iconName); + }); + + it('renders tooltip text', () => { + expect(findTooltipText()).toBe(tooltipText); + }); + }); + + describe('with file unchanged', () => { + beforeEach(() => { + factory({ + file: unchangedFile(), + }); + }); + + it('does not show icon', () => { + expect(findIcon().exists()).toBe(false); + }); + + it('does not have tooltip text', () => { + expect(findTooltipText()).toBe(''); + }); + }); + + it('with size set, sets icon size', () => { + const size = 8; + + factory({ + file: changedFile(), + size, + }); + + expect(findIcon().props('size')).toBe(size); + }); + + // NOTE: It looks like 'showStagedIcon' behavior is backwards to what the name suggests + // https://gitlab.com/gitlab-org/gitlab-ce/issues/66071 + it.each` + showStagedIcon | iconName | desc + ${false} | ${'file-modified-solid'} | ${'with showStagedIcon false, renders staged icon'} + ${true} | ${'file-modified'} | ${'with showStagedIcon true, renders regular icon'} + `('$desc', ({ showStagedIcon, iconName }) => { + factory({ + file: stagedFile(), + showStagedIcon, + }); + + expect(findIconName()).toEqual(iconName); + }); +}); diff --git a/spec/helpers/sessions_helper_spec.rb b/spec/helpers/sessions_helper_spec.rb new file mode 100644 index 00000000000..647771ace92 --- /dev/null +++ b/spec/helpers/sessions_helper_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe SessionsHelper do + describe '#unconfirmed_email?' do + it 'returns true when the flash alert contains a devise failure unconfirmed message' do + flash[:alert] = t(:unconfirmed, scope: [:devise, :failure]) + expect(helper.unconfirmed_email?).to be_truthy + end + + it 'returns false when the flash alert does not contain a devise failure unconfirmed message' do + flash[:alert] = 'something else' + expect(helper.unconfirmed_email?).to be_falsey + end + end +end diff --git a/spec/helpers/tracking_helper_spec.rb b/spec/helpers/tracking_helper_spec.rb index 71505e8ea69..b0c98be4130 100644 --- a/spec/helpers/tracking_helper_spec.rb +++ b/spec/helpers/tracking_helper_spec.rb @@ -4,8 +4,32 @@ require 'spec_helper' describe TrackingHelper do describe '#tracking_attrs' do - it 'returns an empty hash' do - expect(helper.tracking_attrs('a', 'b', 'c')).to eq({}) + using RSpec::Parameterized::TableSyntax + + let(:input) { %w(a b c) } + let(:results) do + { + no_data: {}, + with_data: { data: { track_label: 'a', track_event: 'b', track_property: 'c' } } + } + end + + where(:snowplow_enabled, :environment, :result) do + true | 'production' | :with_data + false | 'production' | :no_data + true | 'development' | :no_data + false | 'development' | :no_data + true | 'test' | :no_data + false | 'test' | :no_data + end + + with_them do + it 'returns a hash' do + stub_application_setting(snowplow_enabled: snowplow_enabled) + allow(Rails).to receive(:env).and_return(environment.inquiry) + + expect(helper.tracking_attrs(*input)).to eq(results[result]) + end end end end diff --git a/spec/javascripts/monitoring/charts/area_spec.js b/spec/javascripts/monitoring/charts/area_spec.js index 4541119dd2e..57f99a09002 100644 --- a/spec/javascripts/monitoring/charts/area_spec.js +++ b/spec/javascripts/monitoring/charts/area_spec.js @@ -24,7 +24,6 @@ describe('Area component', () => { store.commit(`monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`, MonitoringMock.data); store.commit(`monitoringDashboard/${types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS}`, deploymentData); - store.dispatch('monitoringDashboard/setFeatureFlags', { exportMetricsToCsvEnabled: true }); [mockGraphData] = store.state.monitoringDashboard.groups[0].metrics; areaChart = shallowMount(Area, { @@ -109,16 +108,6 @@ describe('Area component', () => { }); }); - describe('when exportMetricsToCsvEnabled is disabled', () => { - beforeEach(() => { - store.dispatch('monitoringDashboard/setFeatureFlags', { exportMetricsToCsvEnabled: false }); - }); - - it('does not render the Download CSV button', () => { - expect(areaChart.contains('glbutton-stub')).toBe(false); - }); - }); - describe('methods', () => { describe('formatTooltipText', () => { const mockDate = deploymentData[0].created_at; @@ -264,23 +253,5 @@ describe('Area component', () => { expect(areaChart.vm.yAxisLabel).toBe('CPU'); }); }); - - describe('csvText', () => { - it('converts data from json to csv', () => { - const header = `timestamp,${mockGraphData.y_label}`; - const data = mockGraphData.queries[0].result[0].values; - const firstRow = `${data[0][0]},${data[0][1]}`; - - expect(areaChart.vm.csvText).toMatch(`^${header}\r\n${firstRow}`); - }); - }); - - describe('downloadLink', () => { - it('produces a link to download metrics as csv', () => { - const link = areaChart.vm.downloadLink; - - expect(link).toContain('blob:'); - }); - }); }); }); diff --git a/spec/javascripts/monitoring/dashboard_spec.js b/spec/javascripts/monitoring/dashboard_spec.js index 36f650d5933..624d8b14c8f 100644 --- a/spec/javascripts/monitoring/dashboard_spec.js +++ b/spec/javascripts/monitoring/dashboard_spec.js @@ -1,11 +1,13 @@ import Vue from 'vue'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlToast } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import Dashboard from '~/monitoring/components/dashboard.vue'; import { timeWindows, timeWindowsKeyNames } from '~/monitoring/constants'; import * as types from '~/monitoring/stores/mutation_types'; import { createStore } from '~/monitoring/stores'; import axios from '~/lib/utils/axios_utils'; -import { +import MonitoringMock, { metricsGroupsAPIResponse, mockApiEndpoint, environmentData, @@ -13,6 +15,7 @@ import { dashboardGitResponse, } from './mock_data'; +const localVue = createLocalVue(); const propsData = { hasMetrics: false, documentationPath: '/path/to/docs', @@ -40,6 +43,7 @@ describe('Dashboard', () => { let mock; let store; let component; + let mockGraphData; beforeEach(() => { setFixtures(` @@ -58,7 +62,9 @@ describe('Dashboard', () => { }); afterEach(() => { - component.$destroy(); + if (component) { + component.$destroy(); + } mock.restore(); }); @@ -372,6 +378,51 @@ describe('Dashboard', () => { }); }); + describe('link to chart', () => { + let wrapper; + const currentDashboard = 'TEST_DASHBOARD'; + localVue.use(GlToast); + const link = () => wrapper.find('.js-chart-link'); + const clipboardText = () => link().element.dataset.clipboardText; + + beforeEach(done => { + mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); + + wrapper = shallowMount(DashboardComponent, { + localVue, + sync: false, + attachToDocument: true, + propsData: { ...propsData, hasMetrics: true, currentDashboard }, + store, + }); + + setTimeout(done); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('adds a copy button to the dropdown', () => { + expect(link().text()).toContain('Generate link to chart'); + }); + + it('contains a link to the dashboard', () => { + expect(clipboardText()).toContain(`dashboard=${currentDashboard}`); + expect(clipboardText()).toContain(`group=`); + expect(clipboardText()).toContain(`title=`); + expect(clipboardText()).toContain(`y_label=`); + }); + + it('creates a toast when clicked', () => { + spyOn(wrapper.vm.$toast, 'show').and.stub(); + + link().vm.$emit('click'); + + expect(wrapper.vm.$toast.show).toHaveBeenCalled(); + }); + }); + describe('when the window resizes', () => { beforeEach(() => { mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); @@ -482,4 +533,36 @@ describe('Dashboard', () => { }); }); }); + + describe('when downloading metrics data as CSV', () => { + beforeEach(() => { + component = new DashboardComponent({ + propsData: { + ...propsData, + }, + store, + }); + store.commit( + `monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`, + MonitoringMock.data, + ); + [mockGraphData] = component.$store.state.monitoringDashboard.groups[0].metrics; + }); + + describe('csvText', () => { + it('converts metrics data from json to csv', () => { + const header = `timestamp,${mockGraphData.y_label}`; + const data = mockGraphData.queries[0].result[0].values; + const firstRow = `${data[0][0]},${data[0][1]}`; + + expect(component.csvText(mockGraphData)).toMatch(`^${header}\r\n${firstRow}`); + }); + }); + + describe('downloadCsv', () => { + it('produces a link with a Blob', () => { + expect(component.downloadCsv(mockGraphData)).toContain(`blob:`); + }); + }); + }); }); diff --git a/spec/javascripts/monitoring/panel_type_spec.js b/spec/javascripts/monitoring/panel_type_spec.js index 8ce24041e97..086be628093 100644 --- a/spec/javascripts/monitoring/panel_type_spec.js +++ b/spec/javascripts/monitoring/panel_type_spec.js @@ -1,20 +1,25 @@ import { shallowMount } from '@vue/test-utils'; import PanelType from '~/monitoring/components/panel_type.vue'; import EmptyChart from '~/monitoring/components/charts/empty_chart.vue'; +import AreaChart from '~/monitoring/components/charts/area.vue'; import { graphDataPrometheusQueryRange } from './mock_data'; +import { createStore } from '~/monitoring/stores'; describe('Panel Type component', () => { + let store; let panelType; const dashboardWidth = 100; describe('When no graphData is available', () => { let glEmptyChart; - const graphDataNoResult = graphDataPrometheusQueryRange; + // Deep clone object before modifying + const graphDataNoResult = JSON.parse(JSON.stringify(graphDataPrometheusQueryRange)); graphDataNoResult.queries[0].result = []; beforeEach(() => { panelType = shallowMount(PanelType, { propsData: { + clipboardText: 'dashboard_link', dashboardWidth, graphData: graphDataNoResult, }, @@ -41,4 +46,33 @@ describe('Panel Type component', () => { }); }); }); + + describe('when Graph data is available', () => { + const exampleText = 'example_text'; + + beforeEach(() => { + store = createStore(); + panelType = shallowMount(PanelType, { + propsData: { + clipboardText: exampleText, + dashboardWidth, + graphData: graphDataPrometheusQueryRange, + }, + store, + }); + }); + + describe('Area Chart panel type', () => { + it('is rendered', () => { + expect(panelType.find(AreaChart).exists()).toBe(true); + }); + + it('sets clipboard text on the dropdown', () => { + const link = () => panelType.find('.js-chart-link'); + const clipboardText = () => link().element.dataset.clipboardText; + + expect(clipboardText()).toBe(exampleText); + }); + }); + }); }); diff --git a/spec/javascripts/registry/components/table_registry_spec.js b/spec/javascripts/registry/components/table_registry_spec.js index 31ac970378e..9c7439206ef 100644 --- a/spec/javascripts/registry/components/table_registry_spec.js +++ b/spec/javascripts/registry/components/table_registry_spec.js @@ -1,61 +1,159 @@ import Vue from 'vue'; import tableRegistry from '~/registry/components/table_registry.vue'; import store from '~/registry/stores'; +import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { repoPropsData } from '../mock_data'; -const [firstImage] = repoPropsData.list; +const [firstImage, secondImage] = repoPropsData.list; describe('table registry', () => { let vm; - let Component; + const Component = Vue.extend(tableRegistry); + const bulkDeletePath = 'path'; const findDeleteBtn = () => vm.$el.querySelector('.js-delete-registry'); + const findDeleteBtnRow = () => vm.$el.querySelector('.js-delete-registry-row'); + const findSelectAllCheckbox = () => vm.$el.querySelector('.js-select-all-checkbox > input'); + const findAllRowCheckboxes = () => + Array.from(vm.$el.querySelectorAll('.js-select-checkbox input')); + const confirmationModal = (child = '') => document.querySelector(`#${vm.modalId} ${child}`); - beforeEach(() => { - Component = Vue.extend(tableRegistry); - vm = new Component({ + const createComponent = () => { + vm = mountComponentWithStore(Component, { store, - propsData: { + props: { repo: repoPropsData, }, - }).$mount(); + }); + }; + + const selectAllCheckboxes = () => vm.selectAll(); + const deselectAllCheckboxes = () => vm.deselectAll(); + + beforeEach(() => { + createComponent(); }); afterEach(() => { vm.$destroy(); }); - it('should render a table with the registry list', () => { - expect(vm.$el.querySelectorAll('table tbody tr').length).toEqual(repoPropsData.list.length); + describe('rendering', () => { + it('should render a table with the registry list', () => { + expect(vm.$el.querySelectorAll('table tbody tr').length).toEqual(repoPropsData.list.length); + }); + + it('should render registry tag', () => { + const textRendered = vm.$el + .querySelector('.table tbody tr') + .textContent.trim() + // replace additional whitespace characters (e.g. new lines) with a single empty space + .replace(/\s\s+/g, ' '); + + expect(textRendered).toContain(repoPropsData.list[0].tag); + expect(textRendered).toContain(repoPropsData.list[0].shortRevision); + expect(textRendered).toContain(repoPropsData.list[0].layers); + expect(textRendered).toContain(repoPropsData.list[0].size); + }); }); - it('should render registry tag', () => { - const textRendered = vm.$el - .querySelector('.table tbody tr') - .textContent.trim() - .replace(/\s\s+/g, ' '); + describe('multi select', () => { + it('should support multiselect and selecting a row should enable delete button', done => { + findSelectAllCheckbox().click(); + selectAllCheckboxes(); + + expect(findSelectAllCheckbox().checked).toBe(true); + + Vue.nextTick(() => { + expect(findDeleteBtn().disabled).toBe(false); + done(); + }); + }); + + it('selecting all checkbox should select all rows and enable delete button', done => { + selectAllCheckboxes(); + + Vue.nextTick(() => { + const checkedValues = findAllRowCheckboxes().filter(x => x.checked); + + expect(checkedValues.length).toBe(repoPropsData.list.length); + done(); + }); + }); + + it('deselecting select all checkbox should deselect all rows and disable delete button', done => { + selectAllCheckboxes(); + deselectAllCheckboxes(); + + Vue.nextTick(() => { + const checkedValues = findAllRowCheckboxes().filter(x => x.checked); + + expect(checkedValues.length).toBe(0); + done(); + }); + }); + + it('should delete multiple items when multiple items are selected', done => { + selectAllCheckboxes(); + + Vue.nextTick(() => { + expect(vm.itemsToBeDeleted).toEqual([0, 1]); + expect(findDeleteBtn().disabled).toBe(false); + + findDeleteBtn().click(); + spyOn(vm, 'multiDeleteItems').and.returnValue(Promise.resolve()); + + Vue.nextTick(() => { + const modal = confirmationModal(); + confirmationModal('.btn-danger').click(); + + expect(modal).toExist(); - expect(textRendered).toContain(repoPropsData.list[0].tag); - expect(textRendered).toContain(repoPropsData.list[0].shortRevision); - expect(textRendered).toContain(repoPropsData.list[0].layers); - expect(textRendered).toContain(repoPropsData.list[0].size); + Vue.nextTick(() => { + expect(vm.itemsToBeDeleted).toEqual([]); + expect(vm.multiDeleteItems).toHaveBeenCalledWith({ + path: bulkDeletePath, + items: [firstImage.tag, secondImage.tag], + }); + done(); + }); + }); + }); + }); }); describe('delete registry', () => { - it('should be possible to delete a registry', () => { - expect(findDeleteBtn()).toBeDefined(); + beforeEach(() => { + vm.itemsToBeDeleted = [0]; }); - it('should call deleteItem and reset itemToBeDeleted when confirming deletion', done => { - findDeleteBtn().click(); - spyOn(vm, 'deleteItem').and.returnValue(Promise.resolve()); + it('should be possible to delete a registry', done => { + Vue.nextTick(() => { + expect(vm.itemsToBeDeleted).toEqual([0]); + expect(findDeleteBtn()).toBeDefined(); + expect(findDeleteBtn().disabled).toBe(false); + expect(findDeleteBtnRow()).toBeDefined(); + done(); + }); + }); + it('should call deleteItems and reset itemsToBeDeleted when confirming deletion', done => { Vue.nextTick(() => { - document.querySelector(`#${vm.modalId} .btn-danger`).click(); + expect(vm.itemsToBeDeleted).toEqual([0]); + expect(findDeleteBtn().disabled).toBe(false); + findDeleteBtn().click(); + spyOn(vm, 'multiDeleteItems').and.returnValue(Promise.resolve()); - expect(vm.deleteItem).toHaveBeenCalledWith(firstImage); - expect(vm.itemToBeDeleted).toBeNull(); - done(); + Vue.nextTick(() => { + confirmationModal('.btn-danger').click(); + + expect(vm.itemsToBeDeleted).toEqual([]); + expect(vm.multiDeleteItems).toHaveBeenCalledWith({ + path: bulkDeletePath, + items: [firstImage.tag], + }); + done(); + }); }); }); }); @@ -65,4 +163,27 @@ describe('table registry', () => { expect(vm.$el.querySelector('.gl-pagination')).toBeDefined(); }); }); + + describe('modal content', () => { + it('should show the singular title and image name when deleting a single image', done => { + findDeleteBtnRow().click(); + + Vue.nextTick(() => { + expect(vm.modalTitle).toBe('Remove image'); + expect(vm.modalDescription).toContain(firstImage.tag); + done(); + }); + }); + + it('should show the plural title and image count when deleting more than one image', done => { + selectAllCheckboxes(); + vm.setModalDescription(); + + Vue.nextTick(() => { + expect(vm.modalTitle).toBe('Remove images'); + expect(vm.modalDescription).toContain('<b>2</b> images'); + done(); + }); + }); + }); }); diff --git a/spec/javascripts/registry/mock_data.js b/spec/javascripts/registry/mock_data.js index 22db203e77f..130ab298e89 100644 --- a/spec/javascripts/registry/mock_data.js +++ b/spec/javascripts/registry/mock_data.js @@ -108,6 +108,17 @@ export const repoPropsData = { destroyPath: 'path', canDelete: true, }, + { + tag: 'test-image', + revision: 'b969de599faea2b3d9b6605a8b0897261c571acaa36db1bdc7349b5775b4e0b4', + shortRevision: 'b969de599', + size: 19, + layers: 10, + location: 'location-2', + createdAt: 1505828744434, + destroyPath: 'path-2', + canDelete: true, + }, ], location: 'location', name: 'foo', diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js index ba3ba01944d..53e1f077610 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -236,24 +236,26 @@ describe('ReadyToMerge', () => { }); }); - describe('shouldShowMergeOptionsDropdown', () => { - it('should return false when no auto merge strategies are available', () => { - Vue.set(vm.mr, 'availableAutoMergeStrategies', []); + describe('shouldShowMergeImmediatelyDropdown', () => { + it('should return false if no pipeline is active', () => { + Vue.set(vm.mr, 'isPipelineActive', false); + Vue.set(vm.mr, 'onlyAllowMergeIfPipelineSucceeds', false); - expect(vm.shouldShowMergeOptionsDropdown).toBe(false); + expect(vm.shouldShowMergeImmediatelyDropdown).toBe(false); }); - it('should return true when at least one auto merge strategy is available', () => { - Vue.set(vm.mr, 'availableAutoMergeStrategies', [ATMTWPS_MERGE_STRATEGY]); + it('should return false if "Pipelines must succeed" is enabled for the current project', () => { + Vue.set(vm.mr, 'isPipelineActive', true); + Vue.set(vm.mr, 'onlyAllowMergeIfPipelineSucceeds', true); - expect(vm.shouldShowMergeOptionsDropdown).toBe(true); + expect(vm.shouldShowMergeImmediatelyDropdown).toBe(false); }); - it('should return false when pipeline active but only merge when pipeline succeeds set in project options', () => { - Vue.set(vm.mr, 'availableAutoMergeStrategies', [ATMTWPS_MERGE_STRATEGY]); - Vue.set(vm.mr, 'onlyAllowMergeIfPipelineSucceeds', true); + it('should return true if the MR\'s pipeline is active and "Pipelines must succeed" is not enabled for the current project', () => { + Vue.set(vm.mr, 'isPipelineActive', true); + Vue.set(vm.mr, 'onlyAllowMergeIfPipelineSucceeds', false); - expect(vm.shouldShowMergeOptionsDropdown).toBe(false); + expect(vm.shouldShowMergeImmediatelyDropdown).toBe(true); }); }); diff --git a/spec/javascripts/vue_mr_widget/mock_data.js b/spec/javascripts/vue_mr_widget/mock_data.js index 253413ae43e..a55d5537df7 100644 --- a/spec/javascripts/vue_mr_widget/mock_data.js +++ b/spec/javascripts/vue_mr_widget/mock_data.js @@ -233,6 +233,8 @@ export default { 'http://localhost:3000/root/acets-app/commit/53027d060246c8f47e4a9310fb332aa52f221775', troubleshooting_docs_path: 'help', merge_request_pipelines_docs_path: '/help/ci/merge_request_pipelines/index.md', + merge_train_when_pipeline_succeeds_docs_path: + '/help/ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/#startadd-to-merge-train-when-pipeline-succeeds', squash: true, visual_review_app_available: true, merge_trains_enabled: true, diff --git a/spec/javascripts/vue_shared/components/changed_file_icon_spec.js b/spec/javascripts/vue_shared/components/changed_file_icon_spec.js deleted file mode 100644 index 634ba8403d5..00000000000 --- a/spec/javascripts/vue_shared/components/changed_file_icon_spec.js +++ /dev/null @@ -1,63 +0,0 @@ -import Vue from 'vue'; -import changedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; -import createComponent from 'spec/helpers/vue_mount_component_helper'; - -describe('Changed file icon', () => { - let vm; - - function factory(props = {}) { - const component = Vue.extend(changedFileIcon); - - vm = createComponent(component, { - ...props, - file: { - tempFile: false, - changed: true, - }, - }); - } - - afterEach(() => { - vm.$destroy(); - }); - - it('centers icon', () => { - factory({ - isCentered: true, - }); - - expect(vm.$el.classList).toContain('ml-auto'); - }); - - describe('changedIcon', () => { - it('equals file-modified when not a temp file and has changes', () => { - factory(); - - expect(vm.changedIcon).toBe('file-modified'); - }); - - it('equals file-addition when a temp file', () => { - factory(); - - vm.file.tempFile = true; - - expect(vm.changedIcon).toBe('file-addition'); - }); - }); - - describe('changedIconClass', () => { - it('includes file-modified when not a temp file', () => { - factory(); - - expect(vm.changedIconClass).toContain('file-modified'); - }); - - it('includes file-addition when a temp file', () => { - factory(); - - vm.file.tempFile = true; - - expect(vm.changedIconClass).toContain('file-addition'); - }); - }); -}); diff --git a/spec/lib/banzai/filter/inline_metrics_filter_spec.rb b/spec/lib/banzai/filter/inline_metrics_filter_spec.rb index 542a9ced6d7..66bbcbf7292 100644 --- a/spec/lib/banzai/filter/inline_metrics_filter_spec.rb +++ b/spec/lib/banzai/filter/inline_metrics_filter_spec.rb @@ -12,7 +12,7 @@ describe Banzai::Filter::InlineMetricsFilter do let(:url) { 'https://foo.com' } it 'leaves regular non-metrics links unchanged' do - expect(doc.to_s).to eq input + expect(doc.to_s).to eq(input) end end @@ -21,7 +21,7 @@ describe Banzai::Filter::InlineMetricsFilter do let(:url) { urls.metrics_namespace_project_environment_url(*params) } it 'leaves the original link unchanged' do - expect(doc.at_css('a').to_s).to eq input + expect(doc.at_css('a').to_s).to eq(input) end it 'appends a metrics charts placeholder with dashboard url after metrics links' do @@ -29,7 +29,7 @@ describe Banzai::Filter::InlineMetricsFilter do expect(node).to be_present dashboard_url = urls.metrics_dashboard_namespace_project_environment_url(*params, embedded: true) - expect(node.attribute('data-dashboard-url').to_s).to eq dashboard_url + expect(node.attribute('data-dashboard-url').to_s).to eq(dashboard_url) end context 'when the metrics dashboard link is part of a paragraph' do @@ -37,9 +37,34 @@ describe Banzai::Filter::InlineMetricsFilter do let(:input) { %(<p>#{paragraph}</p>) } it 'appends the charts placeholder after the enclosing paragraph' do - expect(doc.at_css('p').to_s).to include paragraph + expect(doc.at_css('p').to_s).to include(paragraph) expect(doc.at_css('.js-render-metrics')).to be_present end end + + context 'with dashboard params specified' do + let(:params) do + [ + 'foo', + 'bar', + 12, + { + embedded: true, + dashboard: 'config/prometheus/common_metrics.yml', + group: 'System metrics (Kubernetes)', + title: 'Core Usage (Pod Average)', + y_label: 'Cores per Pod' + } + ] + end + + it 'appends a metrics charts placeholder with dashboard url after metrics links' do + node = doc.at_css('.js-render-metrics') + expect(node).to be_present + + dashboard_url = urls.metrics_dashboard_namespace_project_environment_url(*params) + expect(node.attribute('data-dashboard-url').to_s).to eq(dashboard_url) + end + end end end diff --git a/spec/lib/expand_variables_spec.rb b/spec/lib/expand_variables_spec.rb index 099d7b6b67c..394efa85701 100644 --- a/spec/lib/expand_variables_spec.rb +++ b/spec/lib/expand_variables_spec.rb @@ -4,62 +4,131 @@ require 'spec_helper' describe ExpandVariables do describe '#expand' do - subject { described_class.expand(value, variables) } + context 'table tests' do + using RSpec::Parameterized::TableSyntax - tests = [ - { value: 'key', - result: 'key', - variables: [] }, - { value: 'key$variable', - result: 'key', - variables: [] }, - { value: 'key$variable', - result: 'keyvalue', - variables: [ - { key: 'variable', value: 'value' } - ] }, - { value: 'key${variable}', - result: 'keyvalue', - variables: [ - { key: 'variable', value: 'value' } - ] }, - { value: 'key$variable$variable2', - result: 'keyvalueresult', - variables: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'result' } - ] }, - { value: 'key${variable}${variable2}', - result: 'keyvalueresult', - variables: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'result' } - ] }, - { value: 'key$variable2$variable', - result: 'keyresultvalue', - variables: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'result' } - ] }, - { value: 'key${variable2}${variable}', - result: 'keyresultvalue', - variables: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'result' } - ] }, - { value: 'review/$CI_COMMIT_REF_NAME', - result: 'review/feature/add-review-apps', - variables: [ - { key: 'CI_COMMIT_REF_NAME', value: 'feature/add-review-apps' } - ] } - ] + where do + { + "no expansion": { + value: 'key', + result: 'key', + variables: [] + }, + "missing variable": { + value: 'key$variable', + result: 'key', + variables: [] + }, + "simple expansion": { + value: 'key$variable', + result: 'keyvalue', + variables: [ + { key: 'variable', value: 'value' } + ] + }, + "simple with hash of variables": { + value: 'key$variable', + result: 'keyvalue', + variables: { + 'variable' => 'value' + } + }, + "complex expansion": { + value: 'key${variable}', + result: 'keyvalue', + variables: [ + { key: 'variable', value: 'value' } + ] + }, + "simple expansions": { + value: 'key$variable$variable2', + result: 'keyvalueresult', + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' } + ] + }, + "complex expansions": { + value: 'key${variable}${variable2}', + result: 'keyvalueresult', + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' } + ] + }, + "complex expansions with missing variable": { + value: 'key${variable}${variable2}', + result: 'keyvalue', + variables: [ + { key: 'variable', value: 'value' } + ] + }, + "out-of-order expansion": { + value: 'key$variable2$variable', + result: 'keyresultvalue', + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' } + ] + }, + "out-of-order complex expansion": { + value: 'key${variable2}${variable}', + result: 'keyresultvalue', + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' } + ] + }, + "review-apps expansion": { + value: 'review/$CI_COMMIT_REF_NAME', + result: 'review/feature/add-review-apps', + variables: [ + { key: 'CI_COMMIT_REF_NAME', value: 'feature/add-review-apps' } + ] + }, + "do not lazily access variables when no expansion": { + value: 'key', + result: 'key', + variables: -> { raise NotImplementedError } + }, + "lazily access variables": { + value: 'key$variable', + result: 'keyvalue', + variables: -> { [{ key: 'variable', value: 'value' }] } + } + } + end + + with_them do + subject { ExpandVariables.expand(value, variables) } # rubocop:disable RSpec/DescribedClass + + it { is_expected.to eq(result) } + end + end + + context 'lazily inits variables' do + let(:variables) { -> { [{ key: 'variable', value: 'result' }] } } + + subject { described_class.expand(value, variables) } + + context 'when expanding variable' do + let(:value) { 'key$variable$variable2' } + + it 'calls block at most once' do + expect(variables).to receive(:call).once.and_call_original + + is_expected.to eq('keyresult') + end + end + + context 'when no expansion is needed' do + let(:value) { 'key' } - tests.each do |test| - context "#{test[:value]} resolves to #{test[:result]}" do - let(:value) { test[:value] } - let(:variables) { test[:variables] } + it 'does not call block' do + expect(variables).not_to receive(:call) - it { is_expected.to eq(test[:result]) } + is_expected.to eq('key') + end end end end diff --git a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb index 762025f9bd9..1a9350d68bd 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb @@ -20,20 +20,36 @@ describe Gitlab::Ci::Pipeline::Seed::Build do describe '#bridge?' do subject { seed_build.bridge? } - context 'when job is a bridge' do + context 'when job is a downstream bridge' do let(:attributes) do { name: 'rspec', ref: 'master', options: { trigger: 'my/project' } } end it { is_expected.to be_truthy } + + context 'when trigger definition is empty' do + let(:attributes) do + { name: 'rspec', ref: 'master', options: { trigger: '' } } + end + + it { is_expected.to be_falsey } + end end - context 'when trigger definition is empty' do + context 'when job is an upstream bridge' do let(:attributes) do - { name: 'rspec', ref: 'master', options: { trigger: '' } } + { name: 'rspec', ref: 'master', options: { bridge_needs: { pipeline: 'my/project' } } } end - it { is_expected.to be_falsey } + it { is_expected.to be_truthy } + + context 'when upstream definition is empty' do + let(:attributes) do + { name: 'rspec', ref: 'master', options: { bridge_needs: { pipeline: '' } } } + end + + it { is_expected.to be_falsey } + end end context 'when job is not a bridge' do @@ -386,17 +402,28 @@ describe Gitlab::Ci::Pipeline::Seed::Build do describe 'applying needs: dependency' do subject { seed_build } + let(:needs_count) { 1 } + + let(:needs_attributes) do + Array.new(needs_count, name: 'build') + end + let(:attributes) do { name: 'rspec', - needs_attributes: [{ - name: 'build' - }] + needs_attributes: needs_attributes } end context 'when build job is not present in prior stages' do - it { is_expected.not_to be_included } + it "is included" do + is_expected.to be_included + end + + it "returns an error" do + expect(subject.errors).to contain_exactly( + "rspec: needs 'build'") + end end context 'when build job is part of prior stages' do @@ -414,7 +441,39 @@ describe Gitlab::Ci::Pipeline::Seed::Build do let(:previous_stages) { [stage_seed] } - it { is_expected.to be_included } + it "is included" do + is_expected.to be_included + end + + it "does not have errors" do + expect(subject.errors).to be_empty + end + end + + context 'when lower limit of needs is reached' do + before do + stub_feature_flags(ci_dag_limit_needs: true) + end + + let(:needs_count) { described_class::LOW_NEEDS_LIMIT + 1 } + + it "returns an error" do + expect(subject.errors).to contain_exactly( + "rspec: one job can only need 5 others, but you have listed 6. See needs keyword documentation for more details") + end + end + + context 'when upper limit of needs is reached' do + before do + stub_feature_flags(ci_dag_limit_needs: false) + end + + let(:needs_count) { described_class::HARD_NEEDS_LIMIT + 1 } + + it "returns an error" do + expect(subject.errors).to contain_exactly( + "rspec: one job can only need 50 others, but you have listed 51. See needs keyword documentation for more details") + end end end end diff --git a/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb index 6fba9f37d91..a13335f63d5 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb @@ -121,6 +121,16 @@ describe Gitlab::Ci::Pipeline::Seed::Stage do end end + describe '#seeds_errors' do + it 'returns all errors from seeds' do + expect(subject.seeds.first) + .to receive(:errors) { ["build error"] } + + expect(subject.errors).to contain_exactly( + "build error") + end + end + describe '#to_resource' do it 'builds a valid stage object with all builds' do subject.to_resource.save! diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index 4ffa1fc9fd8..d5567b4f166 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -1153,7 +1153,10 @@ module Gitlab stage_idx: 1, name: "test1", options: { - script: ["test"] + script: ["test"], + # This does not make sense, there is a follow-up: + # https://gitlab.com/gitlab-org/gitlab-ce/issues/65569 + bridge_needs: %w[build1 build2] }, needs_attributes: [ { name: "build1" }, diff --git a/spec/lib/gitlab/danger/helper_spec.rb b/spec/lib/gitlab/danger/helper_spec.rb index f11f68ab3c2..2990594c538 100644 --- a/spec/lib/gitlab/danger/helper_spec.rb +++ b/spec/lib/gitlab/danger/helper_spec.rb @@ -101,13 +101,13 @@ describe Gitlab::Danger::Helper do describe '#changes_by_category' do it 'categorizes changed files' do - expect(fake_git).to receive(:added_files) { %w[foo foo.md foo.rb foo.js db/foo lib/gitlab/database/foo.rb qa/foo ee/changelogs/foo.yml] } + expect(fake_git).to receive(:added_files) { %w[foo foo.md foo.rb foo.js db/migrate/foo lib/gitlab/database/foo.rb qa/foo ee/changelogs/foo.yml] } allow(fake_git).to receive(:modified_files) { [] } allow(fake_git).to receive(:renamed_files) { [] } expect(helper.changes_by_category).to eq( backend: %w[foo.rb], - database: %w[db/foo lib/gitlab/database/foo.rb], + database: %w[db/migrate/foo lib/gitlab/database/foo.rb], frontend: %w[foo.js], none: %w[ee/changelogs/foo.yml foo.md], qa: %w[qa/foo], @@ -173,8 +173,13 @@ describe Gitlab::Danger::Helper do 'ee/FOO_VERSION' | :unknown - 'db/foo' | :database - 'ee/db/foo' | :database + 'db/schema.rb' | :database + 'db/migrate/foo' | :database + 'db/post_migrate/foo' | :database + 'ee/db/migrate/foo' | :database + 'ee/db/post_migrate/foo' | :database + 'ee/db/geo/migrate/foo' | :database + 'ee/db/geo/post_migrate/foo' | :database 'app/models/project_authorization.rb' | :database 'app/services/users/refresh_authorized_projects_service.rb' | :database 'lib/gitlab/background_migration.rb' | :database @@ -188,6 +193,9 @@ describe Gitlab::Danger::Helper do 'lib/gitlab/sql/foo' | :database 'rubocop/cop/migration/foo' | :database + 'db/fixtures/foo.rb' | :backend + 'ee/db/fixtures/foo.rb' | :backend + 'qa/foo' | :qa 'ee/qa/foo' | :qa diff --git a/spec/lib/gitlab/data_builder/push_spec.rb b/spec/lib/gitlab/data_builder/push_spec.rb index cc31f88d365..e8a9f0b06a8 100644 --- a/spec/lib/gitlab/data_builder/push_spec.rb +++ b/spec/lib/gitlab/data_builder/push_spec.rb @@ -3,9 +3,43 @@ require 'spec_helper' describe Gitlab::DataBuilder::Push do + include RepoHelpers + let(:project) { create(:project, :repository) } let(:user) { build(:user, public_email: 'public-email@example.com') } + describe '.build' do + let(:sample) { RepoHelpers.sample_compare } + let(:commits) { project.repository.commits_between(sample.commits.first, sample.commits.last) } + let(:subject) do + described_class.build(project: project, + user: user, + ref: sample.target_branch, + commits: commits, + commits_count: commits.length, + message: 'test message', + with_changed_files: with_changed_files) + end + + context 'with changed files' do + let(:with_changed_files) { true } + + it 'returns commit hook data' do + expect(subject[:project]).to eq(project.hook_attrs) + expect(subject[:commits].first.keys).to include(*%i(added removed modified)) + end + end + + context 'without changed files' do + let(:with_changed_files) { false } + + it 'returns commit hook data without include deltas' do + expect(subject[:project]).to eq(project.hook_attrs) + expect(subject[:commits].first.keys).not_to include(*%i(added removed modified)) + end + end + end + describe '.build_sample' do let(:data) { described_class.build_sample(project, user) } diff --git a/spec/lib/gitlab/git_post_receive_spec.rb b/spec/lib/gitlab/git_post_receive_spec.rb index 1911e954df9..f0df3794e29 100644 --- a/spec/lib/gitlab/git_post_receive_spec.rb +++ b/spec/lib/gitlab/git_post_receive_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe ::Gitlab::GitPostReceive do - let(:project) { create(:project) } + set(:project) { create(:project, :repository) } subject { described_class.new(project, "project-#{project.id}", changes.dup, {}) } @@ -49,4 +49,90 @@ describe ::Gitlab::GitPostReceive do end end end + + describe '#includes_tags?' do + context 'with no tags' do + let(:changes) do + <<~EOF + 654321 210987 refs/notags/tag1 + 654322 210986 refs/heads/test1 + 654323 210985 refs/merge-requests/mr1 + EOF + end + + it 'returns false' do + expect(subject.includes_tags?).to be_falsey + end + end + + context 'with tags' do + let(:changes) do + <<~EOF + 654322 210986 refs/heads/test1 + 654321 210987 refs/tags/tag1 + 654323 210985 refs/merge-requests/mr1 + EOF + end + + it 'returns true' do + expect(subject.includes_tags?).to be_truthy + end + end + + context 'with malformed changes' do + let(:changes) do + <<~EOF + ref/tags/1 a + sometag refs/tags/2 + EOF + end + + it 'returns false' do + expect(subject.includes_tags?).to be_falsey + end + end + end + + describe '#includes_default_branch?' do + context 'with no default branch' do + let(:changes) do + <<~EOF + 654321 210987 refs/heads/test1 + 654322 210986 refs/tags/#{project.default_branch} + 654323 210985 refs/heads/test3 + EOF + end + + it 'returns false' do + expect(subject.includes_default_branch?).to be_falsey + end + end + + context 'with a project with no default branch' do + let(:changes) do + <<~EOF + 654321 210987 refs/heads/test1 + EOF + end + + it 'returns true' do + expect(project).to receive(:default_branch).and_return(nil) + expect(subject.includes_default_branch?).to be_truthy + end + end + + context 'with default branch' do + let(:changes) do + <<~EOF + 654322 210986 refs/heads/test1 + 654321 210987 refs/tags/test2 + 654323 210985 refs/heads/#{project.default_branch} + EOF + end + + it 'returns true' do + expect(subject.includes_default_branch?).to be_truthy + end + end + end end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index fddb5066d6f..3c6b17c10ec 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -242,6 +242,7 @@ project: - cluster_project - cluster_ingresses - creator +- cycle_analytics_stages - group - namespace - boards diff --git a/spec/lib/gitlab/kubernetes/helm/pod_spec.rb b/spec/lib/gitlab/kubernetes/helm/pod_spec.rb index 06c8d127951..fce2aded786 100644 --- a/spec/lib/gitlab/kubernetes/helm/pod_spec.rb +++ b/spec/lib/gitlab/kubernetes/helm/pod_spec.rb @@ -30,7 +30,7 @@ describe Gitlab::Kubernetes::Helm::Pod do it 'generates the appropriate specifications for the container' do container = subject.generate.spec.containers.first expect(container.name).to eq('helm') - expect(container.image).to eq('registry.gitlab.com/gitlab-org/cluster-integration/helm-install-image/releases/2.12.3-kube-1.11.7') + expect(container.image).to eq('registry.gitlab.com/gitlab-org/cluster-integration/helm-install-image/releases/2.14.3-kube-1.11.10') expect(container.env.count).to eq(3) expect(container.env.map(&:name)).to match_array([:HELM_VERSION, :TILLER_NAMESPACE, :COMMAND_SCRIPT]) expect(container.command).to match_array(["/bin/sh"]) diff --git a/spec/lib/gitlab/kubernetes/kubectl_cmd_spec.rb b/spec/lib/gitlab/kubernetes/kubectl_cmd_spec.rb new file mode 100644 index 00000000000..f24ab5579df --- /dev/null +++ b/spec/lib/gitlab/kubernetes/kubectl_cmd_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +describe Gitlab::Kubernetes::KubectlCmd do + describe '.delete' do + it 'constructs string properly' do + args = %w(resource_type type --flag-1 --flag-2) + + expected_command = 'kubectl delete resource_type type --flag-1 --flag-2' + + expect(described_class.delete(*args)).to eq expected_command + end + end + + describe '.apply_file' do + context 'without optional args' do + it 'requires filename to be present' do + expect { described_class.apply_file(nil) }.to raise_error(ArgumentError, "filename is not present") + expect { described_class.apply_file(" ") }.to raise_error(ArgumentError, "filename is not present") + end + + it 'constructs string properly' do + expected_command = 'kubectl apply -f filename' + + expect(described_class.apply_file('filename')).to eq expected_command + end + end + + context 'with optional args' do + it 'constructs command properly with many args' do + args = %w(arg-1 --flag-0-1 arg-2 --flag-0-2) + + expected_command = 'kubectl apply -f filename arg-1 --flag-0-1 arg-2 --flag-0-2' + + expect(described_class.apply_file('filename', *args)).to eq expected_command + end + + it 'constructs command properly with single arg' do + args = "arg-1" + + expected_command = 'kubectl apply -f filename arg-1' + + expect(described_class.apply_file('filename', args)).to eq(expected_command) + end + end + end +end diff --git a/spec/lib/gitlab/metrics/dashboard/url_spec.rb b/spec/lib/gitlab/metrics/dashboard/url_spec.rb index 34bc6359414..e0dc6d98efc 100644 --- a/spec/lib/gitlab/metrics/dashboard/url_spec.rb +++ b/spec/lib/gitlab/metrics/dashboard/url_spec.rb @@ -9,14 +9,22 @@ describe Gitlab::Metrics::Dashboard::Url do end it 'matches a metrics dashboard link with named params' do - url = Gitlab::Routing.url_helpers.metrics_namespace_project_environment_url('foo', 'bar', 1, start: 123345456, anchor: 'title') + url = Gitlab::Routing.url_helpers.metrics_namespace_project_environment_url( + 'foo', + 'bar', + 1, + start: '2019-08-02T05:43:09.000Z', + dashboard: 'config/prometheus/common_metrics.yml', + group: 'awesome group', + anchor: 'title' + ) expected_params = { 'url' => url, 'namespace' => 'foo', 'project' => 'bar', 'environment' => '1', - 'query' => '?start=123345456', + 'query' => '?dashboard=config%2Fprometheus%2Fcommon_metrics.yml&group=awesome+group&start=2019-08-02T05%3A43%3A09.000Z', 'anchor' => '#title' } diff --git a/spec/lib/gitlab/metrics/samplers/puma_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/puma_sampler_spec.rb index f4a6e1fc7d9..b8add3c1324 100644 --- a/spec/lib/gitlab/metrics/samplers/puma_sampler_spec.rb +++ b/spec/lib/gitlab/metrics/samplers/puma_sampler_spec.rb @@ -46,8 +46,6 @@ describe Gitlab::Metrics::Samplers::PumaSampler do expect(subject.metrics[:puma_workers]).to receive(:set).with(labels, 2) expect(subject.metrics[:puma_running_workers]).to receive(:set).with(labels, 2) expect(subject.metrics[:puma_stale_workers]).to receive(:set).with(labels, 0) - expect(subject.metrics[:puma_phase]).to receive(:set).once.with(labels, 2) - expect(subject.metrics[:puma_phase]).to receive(:set).once.with({ worker: 'worker_0' }, 1) subject.sample end diff --git a/spec/lib/gitlab/project_template_spec.rb b/spec/lib/gitlab/project_template_spec.rb index 8b82ea7faa5..c7c82d07508 100644 --- a/spec/lib/gitlab/project_template_spec.rb +++ b/spec/lib/gitlab/project_template_spec.rb @@ -28,6 +28,18 @@ describe Gitlab::ProjectTemplate do end end + describe '#project_path' do + subject { described_class.new('name', 'title', 'description', 'https://gitlab.com/some/project/path').project_path } + + it { is_expected.to eq 'some/project/path' } + end + + describe '#uri_encoded_project_path' do + subject { described_class.new('name', 'title', 'description', 'https://gitlab.com/some/project/path').uri_encoded_project_path } + + it { is_expected.to eq 'some%2Fproject%2Fpath' } + end + describe '.find' do subject { described_class.find(query) } diff --git a/spec/lib/gitlab/snowplow_tracker_spec.rb b/spec/lib/gitlab/snowplow_tracker_spec.rb new file mode 100644 index 00000000000..073a33e5973 --- /dev/null +++ b/spec/lib/gitlab/snowplow_tracker_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe Gitlab::SnowplowTracker do + let(:timestamp) { Time.utc(2017, 3, 22) } + + around do |example| + Timecop.freeze(timestamp) { example.run } + end + + subject { described_class.track_event('epics', 'action', property: 'what', value: 'doit') } + + context '.track_event' do + context 'when Snowplow tracker is disabled' do + it 'does not track the event' do + expect(SnowplowTracker::Tracker).not_to receive(:new) + + subject + end + end + + context 'when Snowplow tracker is enabled' do + before do + stub_application_setting(snowplow_enabled: true) + stub_application_setting(snowplow_site_id: 'awesome gitlab') + stub_application_setting(snowplow_collector_hostname: 'url.com') + end + + it 'tracks the event' do + tracker = double + + expect(::SnowplowTracker::Tracker).to receive(:new) + .with( + an_instance_of(::SnowplowTracker::Emitter), + an_instance_of(::SnowplowTracker::Subject), + 'cf', 'awesome gitlab' + ).and_return(tracker) + expect(tracker).to receive(:track_struct_event) + .with('epics', 'action', nil, 'what', 'doit', nil, timestamp.to_i) + + subject + end + end + end +end diff --git a/spec/lib/gitlab/usage_data_counters/cycle_analytics_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/cycle_analytics_counter_spec.rb new file mode 100644 index 00000000000..71be37692e2 --- /dev/null +++ b/spec/lib/gitlab/usage_data_counters/cycle_analytics_counter_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::UsageDataCounters::CycleAnalyticsCounter do + it_behaves_like 'a redis usage counter', 'CycleAnalytics', :views + + it_behaves_like 'a redis usage counter with totals', :cycle_analytics, views: 3 +end diff --git a/spec/lib/gitlab/usage_data_counters/source_code_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/source_code_counter_spec.rb new file mode 100644 index 00000000000..47077345e0c --- /dev/null +++ b/spec/lib/gitlab/usage_data_counters/source_code_counter_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::UsageDataCounters::SourceCodeCounter do + it_behaves_like 'a redis usage counter', 'Source Code', :pushes + + it_behaves_like 'a redis usage counter with totals', :source_code, pushes: 5 +end diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index bf36273251b..9bbd9394d57 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -59,6 +59,7 @@ describe Gitlab::UsageData do avg_cycle_analytics influxdb_metrics_enabled prometheus_metrics_enabled + cycle_analytics_views )) expect(subject).to include( @@ -71,7 +72,9 @@ describe Gitlab::UsageData do web_ide_views: a_kind_of(Integer), web_ide_commits: a_kind_of(Integer), web_ide_merge_requests: a_kind_of(Integer), - navbar_searches: a_kind_of(Integer) + navbar_searches: a_kind_of(Integer), + cycle_analytics_views: a_kind_of(Integer), + source_code_pushes: a_kind_of(Integer) ) end @@ -151,11 +154,6 @@ describe Gitlab::UsageData do expect(expected_keys - count_data.keys).to be_empty end - it 'does not gather user preferences usage data when the feature is disabled' do - stub_feature_flags(group_overview_security_dashboard: false) - expect(subject[:counts].keys).not_to include(:user_preferences) - end - it 'gathers projects data correctly' do count_data = subject[:counts] diff --git a/spec/models/analytics/cycle_analytics/project_stage_spec.rb b/spec/models/analytics/cycle_analytics/project_stage_spec.rb new file mode 100644 index 00000000000..4e3923e82b1 --- /dev/null +++ b/spec/models/analytics/cycle_analytics/project_stage_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Analytics::CycleAnalytics::ProjectStage do + describe 'associations' do + it { is_expected.to belong_to(:project) } + end +end diff --git a/spec/models/ci/bridge_spec.rb b/spec/models/ci/bridge_spec.rb index eb32198265b..a871f9b3fe6 100644 --- a/spec/models/ci/bridge_spec.rb +++ b/spec/models/ci/bridge_spec.rb @@ -23,7 +23,7 @@ describe Ci::Bridge do let(:status) { bridge.detailed_status(user) } it 'returns detailed status object' do - expect(status).to be_a Gitlab::Ci::Status::Success + expect(status).to be_a Gitlab::Ci::Status::Created end end diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 1fb83fbb088..78be4a8131a 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -1929,6 +1929,13 @@ describe Ci::Pipeline, :mailer do it { is_expected.to be_an(Array) } end + describe '.bridgeable_statuses' do + subject { described_class.bridgeable_statuses } + + it { is_expected.to be_an(Array) } + it { is_expected.not_to include('created', 'preparing', 'pending') } + end + describe '#status' do let(:build) do create(:ci_build, :created, pipeline: pipeline, name: 'test') diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 53424204db7..d344a6d0f0d 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -3015,9 +3015,6 @@ describe MergeRequest do subject { merge_request.rebase_in_progress? } it do - # Stub out the legacy gitaly implementation - allow(merge_request).to receive(:gitaly_rebase_in_progress?) { false } - allow(Gitlab::SidekiqStatus).to receive(:running?).with(rebase_jid) { jid_valid } merge_request.rebase_jid = rebase_jid @@ -3027,42 +3024,6 @@ describe MergeRequest do end end - describe '#gitaly_rebase_in_progress?' do - let(:repo_path) do - Gitlab::GitalyClient::StorageSettings.allow_disk_access do - subject.source_project.repository.path - end - end - let(:rebase_path) { File.join(repo_path, "gitlab-worktree", "rebase-#{subject.id}") } - - before do - system(*%W(#{Gitlab.config.git.bin_path} -C #{repo_path} worktree add --detach #{rebase_path} master)) - end - - it 'returns true when there is a current rebase directory' do - expect(subject.rebase_in_progress?).to be_truthy - end - - it 'returns false when there is no rebase directory' do - FileUtils.rm_rf(rebase_path) - - expect(subject.rebase_in_progress?).to be_falsey - end - - it 'returns false when the rebase directory has expired' do - time = 20.minutes.ago.to_time - File.utime(time, time, rebase_path) - - expect(subject.rebase_in_progress?).to be_falsey - end - - it 'returns false when the source project has been removed' do - allow(subject).to receive(:source_project).and_return(nil) - - expect(subject.rebase_in_progress?).to be_falsey - end - end - describe '#allow_collaboration' do let(:merge_request) do build(:merge_request, source_branch: 'fixes', allow_collaboration: true) diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 2b9c3c43af9..972f26ac745 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -853,4 +853,64 @@ describe Namespace do it { is_expected.to be_falsy } end end + + describe '#emails_disabled?' do + context 'when not a subgroup' do + it 'returns false' do + group = create(:group, emails_disabled: false) + + expect(group.emails_disabled?).to be_falsey + end + + it 'returns true' do + group = create(:group, emails_disabled: true) + + expect(group.emails_disabled?).to be_truthy + end + end + + context 'when a subgroup' do + let(:grandparent) { create(:group) } + let(:parent) { create(:group, parent: grandparent) } + let(:group) { create(:group, parent: parent) } + + it 'returns false' do + expect(group.emails_disabled?).to be_falsey + end + + context 'when ancestor emails are disabled' do + it 'returns true' do + grandparent.update_attribute(:emails_disabled, true) + + expect(group.emails_disabled?).to be_truthy + end + end + end + + context 'when :emails_disabled feature flag is off' do + before do + stub_feature_flags(emails_disabled: false) + end + + context 'when not a subgroup' do + it 'returns false' do + group = create(:group, emails_disabled: true) + + expect(group.emails_disabled?).to be_falsey + end + end + + context 'when a subgroup and ancestor emails are disabled' do + let(:grandparent) { create(:group) } + let(:parent) { create(:group, parent: grandparent) } + let(:group) { create(:group, parent: parent) } + + it 'returns false' do + grandparent.update_attribute(:emails_disabled, true) + + expect(group.emails_disabled?).to be_falsey + end + end + end + end end diff --git a/spec/models/notification_recipient_spec.rb b/spec/models/notification_recipient_spec.rb index 4122736c148..2ba53818e54 100644 --- a/spec/models/notification_recipient_spec.rb +++ b/spec/models/notification_recipient_spec.rb @@ -9,6 +9,38 @@ describe NotificationRecipient do subject(:recipient) { described_class.new(user, :watch, target: target, project: project) } + describe '#notifiable?' do + let(:recipient) { described_class.new(user, :mention, target: target, project: project) } + + context 'when emails are disabled' do + it 'returns false if group disabled' do + expect(project.namespace).to receive(:emails_disabled?).and_return(true) + expect(recipient).to receive(:emails_disabled?).and_call_original + expect(recipient.notifiable?).to eq false + end + + it 'returns false if project disabled' do + expect(project).to receive(:emails_disabled?).and_return(true) + expect(recipient).to receive(:emails_disabled?).and_call_original + expect(recipient.notifiable?).to eq false + end + end + + context 'when emails are enabled' do + it 'returns true if group enabled' do + expect(project.namespace).to receive(:emails_disabled?).and_return(false) + expect(recipient).to receive(:emails_disabled?).and_call_original + expect(recipient.notifiable?).to eq true + end + + it 'returns true if project enabled' do + expect(project).to receive(:emails_disabled?).and_return(false) + expect(recipient).to receive(:emails_disabled?).and_call_original + expect(recipient.notifiable?).to eq true + end + end + end + describe '#has_access?' do before do allow(user).to receive(:can?).and_call_original diff --git a/spec/models/project_services/chat_message/pipeline_message_spec.rb b/spec/models/project_services/chat_message/pipeline_message_spec.rb index 619ab96af94..cf7c7bf7e61 100644 --- a/spec/models/project_services/chat_message/pipeline_message_spec.rb +++ b/spec/models/project_services/chat_message/pipeline_message_spec.rb @@ -42,9 +42,9 @@ describe ChatMessage::PipelineMessage do before do test_commit = double("A test commit", committer: args[:user], title: "A test commit message") - test_project = double("A test project", - commit_by: test_commit, name: args[:project][:name], - web_url: args[:project][:web_url], avatar_url: args[:project][:avatar_url]) + test_project = double("A test project", commit_by: test_commit, name: args[:project][:name], web_url: args[:project][:web_url]) + allow(test_project).to receive(:avatar_url).with(no_args).and_return("/avatar") + allow(test_project).to receive(:avatar_url).with(only_path: false).and_return(args[:project][:avatar_url]) allow(Project).to receive(:find) { test_project } test_pipeline = double("A test pipeline", has_yaml_errors?: has_yaml_errors, diff --git a/spec/models/project_services/emails_on_push_service_spec.rb b/spec/models/project_services/emails_on_push_service_spec.rb index 0a58eb367e3..ffe241aa880 100644 --- a/spec/models/project_services/emails_on_push_service_spec.rb +++ b/spec/models/project_services/emails_on_push_service_spec.rb @@ -20,4 +20,24 @@ describe EmailsOnPushService do it { is_expected.not_to validate_presence_of(:recipients) } end end + + context 'project emails' do + let(:push_data) { { object_kind: 'push' } } + let(:project) { create(:project, :repository) } + let(:service) { create(:emails_on_push_service, project: project) } + + it 'does not send emails when disabled' do + expect(project).to receive(:emails_disabled?).and_return(true) + expect(EmailsOnPushWorker).not_to receive(:perform_async) + + service.execute(push_data) + end + + it 'does send emails when enabled' do + expect(project).to receive(:emails_disabled?).and_return(false) + expect(EmailsOnPushWorker).to receive(:perform_async) + + service.execute(push_data) + end + end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 29a589eba20..ff9e94afc12 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -98,6 +98,7 @@ describe Project do it { is_expected.to have_many(:lfs_file_locks) } it { is_expected.to have_many(:project_deploy_tokens) } it { is_expected.to have_many(:deploy_tokens).through(:project_deploy_tokens) } + it { is_expected.to have_many(:cycle_analytics_stages) } it 'has an inverse relationship with merge requests' do expect(described_class.reflect_on_association(:merge_requests).has_inverse?).to eq(:target_project) @@ -2252,6 +2253,21 @@ describe Project do end end + describe '#mark_stuck_remote_mirrors_as_failed!' do + it 'fails stuck remote mirrors' do + project = create(:project, :repository, :remote_mirror) + + project.remote_mirrors.first.update( + update_status: :started, + last_update_started_at: 2.days.ago + ) + + expect do + project.mark_stuck_remote_mirrors_as_failed! + end.to change { project.remote_mirrors.stuck.count }.from(1).to(0) + end + end + describe '#ancestors_upto' do let(:parent) { create(:group) } let(:child) { create(:group, parent: parent) } @@ -2300,6 +2316,57 @@ describe Project do end end + describe '#emails_disabled?' do + let(:project) { create(:project, emails_disabled: false) } + + context 'emails disabled in group' do + it 'returns true' do + allow(project.namespace).to receive(:emails_disabled?) { true } + + expect(project.emails_disabled?).to be_truthy + end + end + + context 'emails enabled in group' do + before do + allow(project.namespace).to receive(:emails_disabled?) { false } + end + + it 'returns false' do + expect(project.emails_disabled?).to be_falsey + end + + it 'returns true' do + project.update_attribute(:emails_disabled, true) + + expect(project.emails_disabled?).to be_truthy + end + end + + context 'when :emails_disabled feature flag is off' do + before do + stub_feature_flags(emails_disabled: false) + end + + context 'emails disabled in group' do + it 'returns false' do + allow(project.namespace).to receive(:emails_disabled?) { true } + + expect(project.emails_disabled?).to be_falsey + end + end + + context 'emails enabled in group' do + it 'returns false' do + allow(project.namespace).to receive(:emails_disabled?) { false } + project.update_attribute(:emails_disabled, true) + + expect(project.emails_disabled?).to be_falsey + end + end + end + end + describe '#lfs_enabled?' do let(:project) { create(:project) } @@ -4297,6 +4364,39 @@ describe Project do end end + describe '#has_active_hooks?' do + set(:project) { create(:project) } + + it { expect(project.has_active_hooks?).to be_falsey } + + it 'returns true when a matching push hook exists' do + create(:project_hook, push_events: true, project: project) + + expect(project.has_active_hooks?(:merge_request_events)).to be_falsey + expect(project.has_active_hooks?).to be_truthy + end + + it 'returns true when a matching system hook exists' do + create(:system_hook, push_events: true) + + expect(project.has_active_hooks?(:merge_request_events)).to be_falsey + expect(project.has_active_hooks?).to be_truthy + end + end + + describe '#has_active_services?' do + set(:project) { create(:project) } + + it { expect(project.has_active_services?).to be_falsey } + + it 'returns true when a matching service exists' do + create(:custom_issue_tracker_service, push_events: true, merge_requests_events: false, project: project) + + expect(project.has_active_services?(:merge_request_hooks)).to be_falsey + expect(project.has_active_services?).to be_truthy + end + end + describe '#badges' do let(:project_group) { create(:group) } let(:project) { create(:project, path: 'avatar', namespace: project_group) } diff --git a/spec/models/remote_mirror_spec.rb b/spec/models/remote_mirror_spec.rb index 687b0935c55..7edeb56efe2 100644 --- a/spec/models/remote_mirror_spec.rb +++ b/spec/models/remote_mirror_spec.rb @@ -153,14 +153,14 @@ describe RemoteMirror, :mailer do end end - describe '#mark_as_failed' do + describe '#mark_as_failed!' do let(:remote_mirror) { create(:remote_mirror) } let(:error_message) { 'http://user:pass@test.com/root/repoC.git/' } let(:sanitized_error_message) { 'http://*****:*****@test.com/root/repoC.git/' } subject do remote_mirror.update_start - remote_mirror.mark_as_failed(error_message) + remote_mirror.mark_as_failed!(error_message) end it 'sets the update_status to failed' do @@ -204,8 +204,8 @@ describe RemoteMirror, :mailer do it 'includes mirrors that were started over an hour ago' do mirror = create_mirror(url: 'http://cantbeblank', update_status: 'started', - last_update_at: 3.hours.ago, - updated_at: 2.hours.ago) + last_update_started_at: 3.hours.ago, + last_update_at: 2.hours.ago) expect(described_class.stuck.last).to eq(mirror) end @@ -214,7 +214,7 @@ describe RemoteMirror, :mailer do mirror = create_mirror(url: 'http://cantbeblank', update_status: 'started', last_update_at: nil, - updated_at: 4.hours.ago) + last_update_started_at: 4.hours.ago) expect(described_class.stuck.last).to eq(mirror) end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index fa243876632..419e1dc2459 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -1744,12 +1744,23 @@ describe Repository do end end - describe '#before_push_tag' do + describe '#expires_caches_for_tags' do it 'flushes the cache' do expect(repository).to receive(:expire_statistics_caches) expect(repository).to receive(:expire_emptiness_caches) expect(repository).to receive(:expire_tags_cache) + repository.expire_caches_for_tags + end + end + + describe '#before_push_tag' do + it 'logs an event' do + expect(repository).not_to receive(:expire_statistics_caches) + expect(repository).not_to receive(:expire_emptiness_caches) + expect(repository).not_to receive(:expire_tags_cache) + expect(repository).to receive(:repository_event).with(:push_tag) + repository.before_push_tag end end @@ -1804,22 +1815,36 @@ describe Repository do end describe '#after_create' do + it 'calls expire_status_cache' do + expect(repository).to receive(:expire_status_cache) + + repository.after_create + end + + it 'logs an event' do + expect(repository).to receive(:repository_event).with(:create_repository) + + repository.after_create + end + end + + describe '#expire_status_cache' do it 'flushes the exists cache' do expect(repository).to receive(:expire_exists_cache) - repository.after_create + repository.expire_status_cache end it 'flushes the root ref cache' do expect(repository).to receive(:expire_root_ref_cache) - repository.after_create + repository.expire_status_cache end it 'flushes the emptiness caches' do expect(repository).to receive(:expire_emptiness_caches) - repository.after_create + repository.expire_status_cache end end diff --git a/spec/policies/global_policy_spec.rb b/spec/policies/global_policy_spec.rb index 12be3927e18..df6cc526eb0 100644 --- a/spec/policies/global_policy_spec.rb +++ b/spec/policies/global_policy_spec.rb @@ -226,4 +226,32 @@ describe GlobalPolicy do it { is_expected.not_to be_allowed(:read_instance_statistics) } end end + + describe 'slash commands' do + context 'regular user' do + it { is_expected.to be_allowed(:use_slash_commands) } + end + + context 'when internal' do + let(:current_user) { User.ghost } + + it { is_expected.not_to be_allowed(:use_slash_commands) } + end + + context 'when blocked' do + before do + current_user.block + end + + it { is_expected.not_to be_allowed(:use_slash_commands) } + end + + context 'when access locked' do + before do + current_user.lock_access! + end + + it { is_expected.not_to be_allowed(:use_slash_commands) } + end + end end diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb index e8e17228523..5e6ff40e8cf 100644 --- a/spec/requests/api/commits_spec.rb +++ b/spec/requests/api/commits_spec.rb @@ -126,6 +126,12 @@ describe API::Commits do end end + context "with empty ref_name parameter" do + let(:route) { "/projects/#{project_id}/repository/commits?ref_name=" } + + it_behaves_like 'project commits' + end + context "path optional parameter" do it "returns project commits matching provided path parameter" do path = 'files/ruby/popen.rb' diff --git a/spec/requests/api/discussions_spec.rb b/spec/requests/api/discussions_spec.rb index ca1ffe3c524..ef09c6effbb 100644 --- a/spec/requests/api/discussions_spec.rb +++ b/spec/requests/api/discussions_spec.rb @@ -9,6 +9,61 @@ describe API::Discussions do project.add_developer(user) end + context 'with cross-reference system notes', :request_store do + let(:merge_request) { create(:merge_request) } + let(:project) { merge_request.project } + let(:new_merge_request) { create(:merge_request) } + let(:commit) { new_merge_request.project.commit } + let!(:note) { create(:system_note, noteable: merge_request, project: project, note: cross_reference) } + let!(:note_metadata) { create(:system_note_metadata, note: note, action: 'cross_reference') } + let(:cross_reference) { "test commit #{commit.to_reference(project)}" } + let(:pat) { create(:personal_access_token, user: user) } + + let(:url) { "/projects/#{project.id}/merge_requests/#{merge_request.iid}/discussions" } + + before do + project.add_developer(user) + new_merge_request.project.add_developer(user) + end + + it 'returns only the note that the user should see' do + hidden_merge_request = create(:merge_request) + new_cross_reference = "test commit #{hidden_merge_request.project.commit}" + new_note = create(:system_note, noteable: merge_request, project: project, note: new_cross_reference) + create(:system_note_metadata, note: new_note, action: 'cross_reference') + + get api(url, user, personal_access_token: pat) + expect(response).to have_gitlab_http_status(200) + expect(json_response.count).to eq(1) + expect(json_response.first['notes'].count).to eq(1) + + parsed_note = json_response.first['notes'].first + expect(parsed_note['id']).to eq(note.id) + expect(parsed_note['body']).to eq(cross_reference) + expect(parsed_note['system']).to be true + end + + it 'avoids Git calls and N+1 SQL queries' do + expect_any_instance_of(Repository).not_to receive(:find_commit).with(commit.id) + + control = ActiveRecord::QueryRecorder.new do + get api(url, user, personal_access_token: pat) + end + + expect(response).to have_gitlab_http_status(200) + + RequestStore.clear! + + new_note = create(:system_note, noteable: merge_request, project: project, note: cross_reference) + create(:system_note_metadata, note: new_note, action: 'cross_reference') + + RequestStore.clear! + + expect { get api(url, user, personal_access_token: pat) }.not_to exceed_query_limit(control) + expect(response).to have_gitlab_http_status(200) + end + end + context 'when noteable is an Issue' do let!(:issue) { create(:issue, project: project, author: user) } let!(:issue_note) { create(:discussion_note_on_issue, noteable: issue, project: project, author: user) } diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb index 184c00a356a..590107d5161 100644 --- a/spec/requests/api/settings_spec.rb +++ b/spec/requests/api/settings_spec.rb @@ -144,6 +144,7 @@ describe API::Settings, 'Settings' do external_auth_client_key_pass: "5iveL!fe" } end + let(:attribute_names) { settings.keys.map(&:to_s) } it 'includes the attributes in the API' do @@ -165,6 +166,56 @@ describe API::Settings, 'Settings' do end end + context "snowplow tracking settings" do + let(:settings) do + { + snowplow_collector_hostname: "snowplow.example.com", + snowplow_cookie_domain: ".example.com", + snowplow_enabled: true, + snowplow_site_id: "site_id" + } + end + + let(:attribute_names) { settings.keys.map(&:to_s) } + + it "includes the attributes in the API" do + get api("/application/settings", admin) + + expect(response).to have_gitlab_http_status(200) + attribute_names.each do |attribute| + expect(json_response.keys).to include(attribute) + end + end + + it "allows updating the settings" do + put api("/application/settings", admin), params: settings + + expect(response).to have_gitlab_http_status(200) + settings.each do |attribute, value| + expect(ApplicationSetting.current.public_send(attribute)).to eq(value) + end + end + + context "missing snowplow_collector_hostname value when snowplow_enabled is true" do + it "returns a blank parameter error message" do + put api("/application/settings", admin), params: { snowplow_enabled: true } + + expect(response).to have_gitlab_http_status(400) + expect(json_response["error"]).to eq("snowplow_collector_hostname is missing") + end + + it "handles validation errors" do + put api("/application/settings", admin), params: settings.merge({ + snowplow_collector_hostname: nil + }) + + expect(response).to have_gitlab_http_status(400) + message = json_response["message"] + expect(message["snowplow_collector_hostname"]).to include("can't be blank") + end + end + end + context "missing plantuml_url value when plantuml_enabled is true" do it "returns a blank parameter error message" do put api("/application/settings", admin), params: { plantuml_enabled: true } diff --git a/spec/serializers/deployment_entity_spec.rb b/spec/serializers/deployment_entity_spec.rb index 76ad2aee5c5..c0ea2b3c389 100644 --- a/spec/serializers/deployment_entity_spec.rb +++ b/spec/serializers/deployment_entity_spec.rb @@ -32,6 +32,10 @@ describe DeploymentEntity do expect(subject).to include(:created_at) end + it 'exposes finished_at' do + expect(subject).to include(:finished_at) + end + context 'when the pipeline has another manual action' do let(:other_build) { create(:ci_build, :manual, name: 'another deploy', pipeline: pipeline) } let!(:other_deployment) { create(:deployment, deployable: other_build) } diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index 7e2f311a065..deb68899309 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -1113,7 +1113,7 @@ describe Ci::CreatePipelineService do test_a: { stage: "test", script: "ls", - only: %w[master feature tags], + only: %w[master feature], needs: %w[build_a] }, deploy: { @@ -1143,6 +1143,7 @@ describe Ci::CreatePipelineService do it 'does not create a pipeline as test_a depends on build_a' do expect(pipeline).not_to be_persisted expect(pipeline.builds).to be_empty + expect(pipeline.errors[:base]).to contain_exactly("test_a: needs 'build_a'") end end diff --git a/spec/services/git/base_hooks_service_spec.rb b/spec/services/git/base_hooks_service_spec.rb index 4a2ec769116..874df9a68cd 100644 --- a/spec/services/git/base_hooks_service_spec.rb +++ b/spec/services/git/base_hooks_service_spec.rb @@ -14,6 +14,78 @@ describe Git::BaseHooksService do let(:newrev) { "8a2a6eb295bb170b34c24c76c49ed0e9b2eaf34b" } # gitlab-test: git rev-parse refs/tags/v1.1.0 let(:ref) { 'refs/tags/v1.1.0' } + describe '#execute_project_hooks' do + class TestService < described_class + def hook_name + :push_hooks + end + + def commits + [] + end + end + + let(:project) { create(:project, :repository) } + + subject { TestService.new(project, user, oldrev: oldrev, newrev: newrev, ref: ref) } + + context '#execute_hooks' do + before do + expect(project).to receive(:has_active_hooks?).and_return(active) + end + + context 'active hooks' do + let(:active) { true } + + it 'executes the hooks' do + expect(subject).to receive(:push_data).at_least(:once).and_call_original + expect(project).to receive(:execute_hooks) + + subject.execute + end + end + + context 'inactive hooks' do + let(:active) { false } + + it 'does not execute the hooks' do + expect(subject).not_to receive(:push_data) + expect(project).not_to receive(:execute_hooks) + + subject.execute + end + end + end + + context '#execute_services' do + before do + expect(project).to receive(:has_active_services?).and_return(active) + end + + context 'active services' do + let(:active) { true } + + it 'executes the services' do + expect(subject).to receive(:push_data).at_least(:once).and_call_original + expect(project).to receive(:execute_services) + + subject.execute + end + end + + context 'inactive services' do + let(:active) { false } + + it 'does not execute the services' do + expect(subject).not_to receive(:push_data) + expect(project).not_to receive(:execute_services) + + subject.execute + end + end + end + end + describe 'with remote mirrors' do class TestService < described_class def commits diff --git a/spec/services/git/branch_hooks_service_spec.rb b/spec/services/git/branch_hooks_service_spec.rb index 41180402759..2bf7dc32436 100644 --- a/spec/services/git/branch_hooks_service_spec.rb +++ b/spec/services/git/branch_hooks_service_spec.rb @@ -26,7 +26,7 @@ describe Git::BranchHooksService do end describe "Git Push Data" do - subject(:push_data) { service.execute } + subject(:push_data) { service.send(:push_data) } it 'has expected push data attributes' do is_expected.to match a_hash_including( @@ -110,6 +110,7 @@ describe Git::BranchHooksService do expect(event.push_event_payload).to be_an_instance_of(PushEventPayload) expect(event.push_event_payload.commit_from).to eq(oldrev) expect(event.push_event_payload.commit_to).to eq(newrev) + expect(event.push_event_payload.commit_title).to eq('Change some files') expect(event.push_event_payload.ref).to eq('master') expect(event.push_event_payload.commit_count).to eq(1) end @@ -125,6 +126,7 @@ describe Git::BranchHooksService do expect(event.push_event_payload).to be_an_instance_of(PushEventPayload) expect(event.push_event_payload.commit_from).to be_nil expect(event.push_event_payload.commit_to).to eq(newrev) + expect(event.push_event_payload.commit_title).to eq('Initial commit') expect(event.push_event_payload.ref).to eq('master') expect(event.push_event_payload.commit_count).to be > 1 end @@ -157,9 +159,13 @@ describe Git::BranchHooksService do let(:blank_sha) { Gitlab::Git::BLANK_SHA } def clears_cache(extended: []) - expect(ProjectCacheWorker) - .to receive(:perform_async) - .with(project.id, extended, %i[commit_count repository_size]) + expect(service).to receive(:invalidated_file_types).and_return(extended) + + if extended.present? + expect(ProjectCacheWorker) + .to receive(:perform_async) + .with(project.id, extended, [], false) + end service.execute end diff --git a/spec/services/git/branch_push_service_spec.rb b/spec/services/git/branch_push_service_spec.rb index 6e39fa6b3c0..ad5d296f5c1 100644 --- a/spec/services/git/branch_push_service_spec.rb +++ b/spec/services/git/branch_push_service_spec.rb @@ -78,7 +78,10 @@ describe Git::BranchPushService, services: true do it "creates a new pipeline" do expect { subject }.to change { Ci::Pipeline.count } - expect(Ci::Pipeline.last).to be_push + + pipeline = Ci::Pipeline.last + expect(pipeline).to be_push + expect(Gitlab::Git::BRANCH_REF_PREFIX + pipeline.ref).to eq(ref) end end @@ -123,6 +126,10 @@ describe Git::BranchPushService, services: true do describe "Webhooks" do context "execute webhooks" do + before do + create(:project_hook, push_events: true, project: project) + end + it "when pushing a branch for the first time" do expect(project).to receive(:execute_hooks) expect(project.default_branch).to eq("master") diff --git a/spec/services/git/tag_hooks_service_spec.rb b/spec/services/git/tag_hooks_service_spec.rb index f5938a5c708..e362577d289 100644 --- a/spec/services/git/tag_hooks_service_spec.rb +++ b/spec/services/git/tag_hooks_service_spec.rb @@ -26,7 +26,8 @@ describe Git::TagHooksService, :service do describe 'System hooks' do it 'Executes system hooks' do - push_data = service.execute + push_data = service.send(:push_data) + expect(project).to receive(:has_active_hooks?).and_return(true) expect_next_instance_of(SystemHooksService) do |system_hooks_service| expect(system_hooks_service) @@ -40,6 +41,7 @@ describe Git::TagHooksService, :service do describe "Webhooks" do it "executes hooks on the project" do + expect(project).to receive(:has_active_hooks?).and_return(true) expect(project).to receive(:execute_hooks) service.execute @@ -61,7 +63,7 @@ describe Git::TagHooksService, :service do describe 'Push data' do shared_examples_for 'tag push data expectations' do - subject(:push_data) { service.execute } + subject(:push_data) { service.send(:push_data) } it 'has expected push data attributes' do is_expected.to match a_hash_including( object_kind: 'tag_push', diff --git a/spec/services/git/tag_push_service_spec.rb b/spec/services/git/tag_push_service_spec.rb index 418952b52da..7e008637182 100644 --- a/spec/services/git/tag_push_service_spec.rb +++ b/spec/services/git/tag_push_service_spec.rb @@ -26,8 +26,8 @@ describe Git::TagPushService do subject end - it 'flushes the tags cache' do - expect(project.repository).to receive(:expire_tags_cache) + it 'does not flush the tags cache' do + expect(project.repository).not_to receive(:expire_tags_cache) subject end diff --git a/spec/services/groups/update_service_spec.rb b/spec/services/groups/update_service_spec.rb index 5d4576139f7..12e9c2b2f3a 100644 --- a/spec/services/groups/update_service_spec.rb +++ b/spec/services/groups/update_service_spec.rb @@ -86,6 +86,7 @@ describe Groups::UpdateService do context "unauthorized visibility_level validation" do let!(:service) { described_class.new(internal_group, user, visibility_level: 99) } + before do internal_group.add_user(user, Gitlab::Access::MAINTAINER) end @@ -96,6 +97,20 @@ describe Groups::UpdateService do end end + context 'when updating #emails_disabled' do + let(:service) { described_class.new(internal_group, user, emails_disabled: true) } + + it 'updates the attribute' do + internal_group.add_user(user, Gitlab::Access::OWNER) + + expect { service.execute }.to change { internal_group.emails_disabled }.to(true) + end + + it 'does not update when not group owner' do + expect { service.execute }.not_to change { internal_group.emails_disabled } + end + end + context 'rename group' do let!(:service) { described_class.new(internal_group, user, path: SecureRandom.hex) } diff --git a/spec/services/merge_requests/rebase_service_spec.rb b/spec/services/merge_requests/rebase_service_spec.rb index ee9caaf2f47..7b8c94c86fe 100644 --- a/spec/services/merge_requests/rebase_service_spec.rb +++ b/spec/services/merge_requests/rebase_service_spec.rb @@ -25,7 +25,7 @@ describe MergeRequests::RebaseService do describe '#execute' do context 'when another rebase is already in progress' do before do - allow(merge_request).to receive(:gitaly_rebase_in_progress?).and_return(true) + allow(repository).to receive(:rebase_in_progress?).with(merge_request.id).and_return(true) end it 'saves the error message' do diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 1dcade1de0d..d925aa2b6c3 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -240,45 +240,50 @@ describe NotificationService, :mailer do end describe '#new_note' do - it do - add_users(project) - add_user_subscriptions(issue) - reset_delivered_emails! + context do + before do + add_users(project) + add_user_subscriptions(issue) + reset_delivered_emails! + end - expect(SentNotification).to receive(:record).with(issue, any_args).exactly(10).times + it do + expect(SentNotification).to receive(:record).with(issue, any_args).exactly(10).times - notification.new_note(note) + notification.new_note(note) - should_email(@u_watcher) - should_email(note.noteable.author) - should_email(note.noteable.assignees.first) - should_email(@u_custom_global) - should_email(@u_mentioned) - should_email(@subscriber) - should_email(@watcher_and_subscriber) - should_email(@subscribed_participant) - should_email(@u_custom_off) - should_email(@unsubscribed_mentioned) - should_not_email(@u_guest_custom) - should_not_email(@u_guest_watcher) - should_not_email(note.author) - should_not_email(@u_participating) - should_not_email(@u_disabled) - should_not_email(@unsubscriber) - should_not_email(@u_outsider_mentioned) - should_not_email(@u_lazy_participant) - end + should_email(@u_watcher) + should_email(note.noteable.author) + should_email(note.noteable.assignees.first) + should_email(@u_custom_global) + should_email(@u_mentioned) + should_email(@subscriber) + should_email(@watcher_and_subscriber) + should_email(@subscribed_participant) + should_email(@u_custom_off) + should_email(@unsubscribed_mentioned) + should_not_email(@u_guest_custom) + should_not_email(@u_guest_watcher) + should_not_email(note.author) + should_not_email(@u_participating) + should_not_email(@u_disabled) + should_not_email(@unsubscriber) + should_not_email(@u_outsider_mentioned) + should_not_email(@u_lazy_participant) + end - it "emails the note author if they've opted into notifications about their activity" do - add_users(project) - add_user_subscriptions(issue) - reset_delivered_emails! + it "emails the note author if they've opted into notifications about their activity" do + note.author.notified_of_own_activity = true - note.author.notified_of_own_activity = true + notification.new_note(note) - notification.new_note(note) + should_email(note.author) + end - should_email(note.author) + it_behaves_like 'project emails are disabled' do + let(:notification_target) { note } + let(:notification_trigger) { notification.new_note(note) } + end end it 'filters out "mentioned in" notes' do @@ -337,6 +342,11 @@ describe NotificationService, :mailer do it_behaves_like 'new note notifications' + it_behaves_like 'project emails are disabled' do + let(:notification_target) { note } + let(:notification_trigger) { notification.new_note(note) } + end + context 'which is a subgroup' do let!(:parent) { create(:group) } let!(:group) { create(:group, parent: parent) } @@ -472,6 +482,11 @@ describe NotificationService, :mailer do expect(Notify).not_to receive(:note_issue_email) notification.new_note(mentioned_note) end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { note } + let(:notification_trigger) { notification.new_note(note) } + end end end @@ -619,6 +634,11 @@ describe NotificationService, :mailer do notification.new_note(note) should_not_email(@u_committer) end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { note } + let(:notification_trigger) { notification.new_note(note) } + end end end @@ -645,6 +665,11 @@ describe NotificationService, :mailer do .to contain_exactly(*merge_request.assignees.pluck(:id), merge_request.author.id, @u_watcher.id) expect(SentNotification.last.in_reply_to_discussion_id).to eq(note.discussion_id) end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { note } + let(:notification_trigger) { notification.new_note(note) } + end end end end @@ -819,6 +844,11 @@ describe NotificationService, :mailer do should_email(user_4) end + it_behaves_like 'project emails are disabled' do + let(:notification_target) { issue } + let(:notification_trigger) { notification.new_issue(issue, @u_disabled) } + end + context 'confidential issues' do let(:author) { create(:user) } let(:assignee) { create(:user) } @@ -861,6 +891,11 @@ describe NotificationService, :mailer do let(:mentionable) { issue } include_examples 'notifications for new mentions' + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { issue } + let(:notification_trigger) { send_notifications(@u_watcher, @u_participant_mentioned, @u_custom_global, @u_mentioned) } + end end describe '#reassigned_issue' do @@ -969,6 +1004,11 @@ describe NotificationService, :mailer do let(:issuable) { issue } let(:notification_trigger) { notification.reassigned_issue(issue, @u_disabled, [assignee]) } end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { issue } + let(:notification_trigger) { notification.reassigned_issue(issue, @u_disabled, [assignee]) } + end end describe '#relabeled_issue' do @@ -1028,6 +1068,11 @@ describe NotificationService, :mailer do should_email(subscriber_to_both) end + it_behaves_like 'project emails are disabled' do + let(:notification_target) { issue } + let(:notification_trigger) { notification.relabeled_issue(issue, [group_label_2, label_2], @u_disabled) } + end + context 'confidential issues' do let(:author) { create(:user) } let(:assignee) { create(:user) } @@ -1065,12 +1110,19 @@ describe NotificationService, :mailer do end describe '#removed_milestone_issue' do - it_behaves_like 'altered milestone notification on issue' do + context do let(:milestone) { create(:milestone, project: project, issues: [issue]) } let!(:subscriber_to_new_milestone) { create(:user) { |u| issue.toggle_subscription(u, project) } } - before do - notification.removed_milestone_issue(issue, issue.author) + it_behaves_like 'altered milestone notification on issue' do + before do + notification.removed_milestone_issue(issue, issue.author) + end + end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { issue } + let(:notification_trigger) { notification.removed_milestone_issue(issue, issue.author) } end end @@ -1110,12 +1162,19 @@ describe NotificationService, :mailer do end describe '#changed_milestone_issue' do - it_behaves_like 'altered milestone notification on issue' do + context do let(:new_milestone) { create(:milestone, project: project, issues: [issue]) } let!(:subscriber_to_new_milestone) { create(:user) { |u| issue.toggle_subscription(u, project) } } - before do - notification.changed_milestone_issue(issue, new_milestone, issue.author) + it_behaves_like 'altered milestone notification on issue' do + before do + notification.changed_milestone_issue(issue, new_milestone, issue.author) + end + end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { issue } + let(:notification_trigger) { notification.changed_milestone_issue(issue, new_milestone, issue.author) } end end @@ -1183,6 +1242,11 @@ describe NotificationService, :mailer do let(:issuable) { issue } let(:notification_trigger) { notification.close_issue(issue, @u_disabled) } end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { issue } + let(:notification_trigger) { notification.close_issue(issue, @u_disabled) } + end end describe '#reopen_issue' do @@ -1214,6 +1278,11 @@ describe NotificationService, :mailer do let(:issuable) { issue } let(:notification_trigger) { notification.reopen_issue(issue, @u_disabled) } end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { issue } + let(:notification_trigger) { notification.reopen_issue(issue, @u_disabled) } + end end describe '#issue_moved' do @@ -1240,6 +1309,11 @@ describe NotificationService, :mailer do let(:issuable) { issue } let(:notification_trigger) { notification.issue_moved(issue, new_issue, @u_disabled) } end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { issue } + let(:notification_trigger) { notification.issue_moved(issue, new_issue, @u_disabled) } + end end describe '#issue_due' do @@ -1280,6 +1354,11 @@ describe NotificationService, :mailer do let(:issuable) { issue } let(:notification_trigger) { notification.issue_due(issue) } end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { issue } + let(:notification_trigger) { notification.issue_due(issue) } + end end end @@ -1374,6 +1453,11 @@ describe NotificationService, :mailer do should_email(user_4) end + it_behaves_like 'project emails are disabled' do + let(:notification_target) { merge_request } + let(:notification_trigger) { notification.new_merge_request(merge_request, @u_disabled) } + end + context 'participating' do it_should_behave_like 'participating by assignee notification' do let(:participant) { create(:user, username: 'user-participant')} @@ -1406,6 +1490,11 @@ describe NotificationService, :mailer do let(:mentionable) { merge_request } include_examples 'notifications for new mentions' + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { merge_request } + let(:notification_trigger) { send_notifications(@u_watcher, @u_participant_mentioned, @u_custom_global, @u_mentioned) } + end end describe '#reassigned_merge_request' do @@ -1449,6 +1538,11 @@ describe NotificationService, :mailer do let(:issuable) { merge_request } let(:notification_trigger) { notification.reassigned_merge_request(merge_request, current_user, [assignee]) } end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { merge_request } + let(:notification_trigger) { notification.reassigned_merge_request(merge_request, current_user, [assignee]) } + end end describe '#push_to_merge_request' do @@ -1479,6 +1573,11 @@ describe NotificationService, :mailer do let(:issuable) { merge_request } let(:notification_trigger) { notification.push_to_merge_request(merge_request, @u_disabled) } end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { merge_request } + let(:notification_trigger) { notification.push_to_merge_request(merge_request, @u_disabled) } + end end describe '#relabel_merge_request' do @@ -1512,28 +1611,43 @@ describe NotificationService, :mailer do should_not_email(@u_participating) should_not_email(@u_lazy_participant) end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { merge_request } + let(:notification_trigger) { notification.relabeled_merge_request(merge_request, [group_label_2, label_2], @u_disabled) } + end end describe '#removed_milestone_merge_request' do - it_behaves_like 'altered milestone notification on merge request' do - let(:milestone) { create(:milestone, project: project, merge_requests: [merge_request]) } - let!(:subscriber_to_new_milestone) { create(:user) { |u| merge_request.toggle_subscription(u, project) } } + let(:milestone) { create(:milestone, project: project, merge_requests: [merge_request]) } + let!(:subscriber_to_new_milestone) { create(:user) { |u| merge_request.toggle_subscription(u, project) } } + it_behaves_like 'altered milestone notification on merge request' do before do notification.removed_milestone_merge_request(merge_request, merge_request.author) end end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { merge_request } + let(:notification_trigger) { notification.removed_milestone_merge_request(merge_request, merge_request.author) } + end end describe '#changed_milestone_merge_request' do - it_behaves_like 'altered milestone notification on merge request' do - let(:new_milestone) { create(:milestone, project: project, merge_requests: [merge_request]) } - let!(:subscriber_to_new_milestone) { create(:user) { |u| merge_request.toggle_subscription(u, project) } } + let(:new_milestone) { create(:milestone, project: project, merge_requests: [merge_request]) } + let!(:subscriber_to_new_milestone) { create(:user) { |u| merge_request.toggle_subscription(u, project) } } + it_behaves_like 'altered milestone notification on merge request' do before do notification.changed_milestone_merge_request(merge_request, new_milestone, merge_request.author) end end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { merge_request } + let(:notification_trigger) { notification.changed_milestone_merge_request(merge_request, new_milestone, merge_request.author) } + end end describe '#merge_request_unmergeable' do @@ -1544,6 +1658,11 @@ describe NotificationService, :mailer do expect(email_recipients.size).to eq(1) end + it_behaves_like 'project emails are disabled' do + let(:notification_target) { merge_request } + let(:notification_trigger) { notification.merge_request_unmergeable(merge_request) } + end + describe 'when merge_when_pipeline_succeeds is true' do before do merge_request.update( @@ -1590,6 +1709,11 @@ describe NotificationService, :mailer do let(:issuable) { merge_request } let(:notification_trigger) { notification.close_mr(merge_request, @u_disabled) } end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { merge_request } + let(:notification_trigger) { notification.close_mr(merge_request, @u_disabled) } + end end describe '#merged_merge_request' do @@ -1642,6 +1766,11 @@ describe NotificationService, :mailer do let(:issuable) { merge_request } let(:notification_trigger) { notification.merge_mr(merge_request, @u_disabled) } end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { merge_request } + let(:notification_trigger) { notification.merge_mr(merge_request, @u_disabled) } + end end describe '#reopen_merge_request' do @@ -1672,6 +1801,11 @@ describe NotificationService, :mailer do let(:issuable) { merge_request } let(:notification_trigger) { notification.reopen_mr(merge_request, @u_disabled) } end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { merge_request } + let(:notification_trigger) { notification.reopen_mr(merge_request, @u_disabled) } + end end describe "#resolve_all_discussions" do @@ -1695,6 +1829,11 @@ describe NotificationService, :mailer do let(:issuable) { merge_request } let(:notification_trigger) { notification.resolve_all_discussions(merge_request, @u_disabled) } end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { merge_request } + let(:notification_trigger) { notification.resolve_all_discussions(merge_request, @u_disabled) } + end end end @@ -1719,6 +1858,11 @@ describe NotificationService, :mailer do should_not_email(@u_disabled) end + it_behaves_like 'project emails are disabled' do + let(:notification_target) { project } + let(:notification_trigger) { notification.project_was_moved(project, "gitlab/gitlab") } + end + context 'users not having access to the new location' do it 'does not send email' do old_user = create(:user) @@ -1762,6 +1906,11 @@ describe NotificationService, :mailer do should_only_email(@u_participating) end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { project } + let(:notification_trigger) { notification.project_exported(project, @u_participating) } + end end describe '#project_not_exported' do @@ -1770,6 +1919,11 @@ describe NotificationService, :mailer do should_only_email(@u_participating) end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { project } + let(:notification_trigger) { notification.project_not_exported(project, @u_participating, ['error']) } + end end end end @@ -1800,6 +1954,11 @@ describe NotificationService, :mailer do should_email(maintainer) should_not_email(developer) end + + it_behaves_like 'group emails are disabled' do + let(:notification_target) { group } + let(:notification_trigger) { group.request_access(added_user) } + end end describe '#decline_group_invite' do @@ -1839,6 +1998,11 @@ describe NotificationService, :mailer do should_not_email_anyone end end + + it_behaves_like 'group emails are disabled' do + let(:notification_target) { group } + let(:notification_trigger) { group.add_guest(added_user) } + end end end @@ -1859,6 +2023,11 @@ describe NotificationService, :mailer do should_only_email(project.owner) end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { project } + let(:notification_trigger) { project.request_access(added_user) } + end end context 'for a project in a group' do @@ -1878,7 +2047,7 @@ describe NotificationService, :mailer do end end - describe '#decline_group_invite' do + describe '#decline_project_invite' do let(:member) { create(:user) } before do @@ -1900,6 +2069,11 @@ describe NotificationService, :mailer do should_only_email(added_user) end + it_behaves_like 'project emails are disabled' do + let(:notification_target) { project } + let(:notification_trigger) { create_member! } + end + context 'when notifications are disabled' do before do create_global_setting_for(added_user, :disabled) @@ -2071,6 +2245,11 @@ describe NotificationService, :mailer do should_only_email(u_custom_notification_enabled, kind: :bcc) end + it_behaves_like 'project emails are disabled' do + let(:notification_target) { pipeline } + let(:notification_trigger) { notification.pipeline_finished(pipeline) } + end + context 'when the creator has group notification email set' do let(:group_notification_email) { 'user+group@example.com' } @@ -2100,6 +2279,11 @@ describe NotificationService, :mailer do should_only_email(u_member, kind: :bcc) end + it_behaves_like 'project emails are disabled' do + let(:notification_target) { pipeline } + let(:notification_trigger) { notification.pipeline_finished(pipeline) } + end + context 'when the creator has group notification email set' do let(:group_notification_email) { 'user+group@example.com' } @@ -2215,6 +2399,11 @@ describe NotificationService, :mailer do should_only_email(u_maintainer1, u_maintainer2, u_owner) end + it_behaves_like 'project emails are disabled' do + let(:notification_target) { domain } + let(:notification_trigger) { notify! } + end + it 'emails nobody if the project is missing' do domain.project = nil @@ -2224,30 +2413,6 @@ describe NotificationService, :mailer do end end end - - describe '#pages_domain_verification_failed' do - it 'emails current watching maintainers' do - notification.pages_domain_verification_failed(domain) - - should_only_email(u_maintainer1, u_maintainer2, u_owner) - end - end - - describe '#pages_domain_enabled' do - it 'emails current watching maintainers' do - notification.pages_domain_enabled(domain) - - should_only_email(u_maintainer1, u_maintainer2, u_owner) - end - end - - describe '#pages_domain_disabled' do - it 'emails current watching maintainers' do - notification.pages_domain_disabled(domain) - - should_only_email(u_maintainer1, u_maintainer2, u_owner) - end - end end context 'Auto DevOps notifications' do @@ -2266,6 +2431,11 @@ describe NotificationService, :mailer do should_email(owner, times: 1) # Once for the disable pipeline. should_email(pipeline_user, times: 2) # Once for the new permission, once for the disable. end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { project } + let(:notification_trigger) { notification.autodevops_disabled(pipeline, [owner.email, pipeline_user.email]) } + end end end @@ -2279,6 +2449,11 @@ describe NotificationService, :mailer do should_email(user) end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { project } + let(:notification_trigger) { notification.repository_cleanup_success(project, user) } + end end describe '#repository_cleanup_failure' do @@ -2287,6 +2462,11 @@ describe NotificationService, :mailer do should_email(user) end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { project } + let(:notification_trigger) { notification.repository_cleanup_failure(project, user, 'Some error') } + end end end @@ -2320,6 +2500,11 @@ describe NotificationService, :mailer do should_only_email(u_maintainer1, u_maintainer2, u_owner) end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { project } + let(:notification_trigger) { notification.remote_mirror_update_failed(remote_mirror) } + end end end diff --git a/spec/services/projects/update_remote_mirror_service_spec.rb b/spec/services/projects/update_remote_mirror_service_spec.rb index be2811ab1e7..4396ccab584 100644 --- a/spec/services/projects/update_remote_mirror_service_spec.rb +++ b/spec/services/projects/update_remote_mirror_service_spec.rb @@ -10,49 +10,91 @@ describe Projects::UpdateRemoteMirrorService do subject(:service) { described_class.new(project, project.creator) } - describe "#execute" do + describe '#execute' do + subject(:execute!) { service.execute(remote_mirror, 0) } + before do project.repository.add_branch(project.owner, 'existing-branch', 'master') allow(remote_mirror).to receive(:update_repository).and_return(true) end - it "ensures the remote exists" do + it 'ensures the remote exists' do stub_fetch_remote(project, remote_name: remote_name, ssh_auth: remote_mirror) expect(remote_mirror).to receive(:ensure_remote!) - service.execute(remote_mirror) + execute! end - it "fetches the remote repository" do + it 'fetches the remote repository' do expect(project.repository) .to receive(:fetch_remote) - .with(remote_mirror.remote_name, no_tags: true, ssh_auth: remote_mirror) + .with(remote_mirror.remote_name, no_tags: true, ssh_auth: remote_mirror) - service.execute(remote_mirror) + execute! end - it "returns success when updated succeeds" do + it 'marks the mirror as started when beginning' do + expect(remote_mirror).to receive(:update_start!).and_call_original + + execute! + end + + it 'marks the mirror as successfully finished' do stub_fetch_remote(project, remote_name: remote_name, ssh_auth: remote_mirror) - result = service.execute(remote_mirror) + result = execute! expect(result[:status]).to eq(:success) + expect(remote_mirror).to be_finished + end + + it 'marks the mirror as failed and raises the error when an unexpected error occurs' do + allow(project.repository).to receive(:fetch_remote).and_raise('Badly broken') + + expect { execute! }.to raise_error /Badly broken/ + + expect(remote_mirror).to be_failed + expect(remote_mirror.last_error).to include('Badly broken') + end + + context 'when the update fails because of a `Gitlab::Git::CommandError`' do + before do + allow(project.repository).to receive(:fetch_remote).and_raise(Gitlab::Git::CommandError.new('fetch failed')) + end + + it 'wraps `Gitlab::Git::CommandError`s in a service error' do + expect(execute!).to eq(status: :error, message: 'fetch failed') + end + + it 'marks the mirror as to be retried' do + execute! + + expect(remote_mirror).to be_to_retry + expect(remote_mirror.last_error).to include('fetch failed') + end + + it "marks the mirror as failed after #{described_class::MAX_TRIES} tries" do + service.execute(remote_mirror, described_class::MAX_TRIES) + + expect(remote_mirror).to be_failed + expect(remote_mirror.last_error).to include('fetch failed') + end end context 'when syncing all branches' do - it "push all the branches the first time" do + it 'push all the branches the first time' do stub_fetch_remote(project, remote_name: remote_name, ssh_auth: remote_mirror) expect(remote_mirror).to receive(:update_repository).with({}) - service.execute(remote_mirror) + execute! end end context 'when only syncing protected branches' do - it "sync updated protected branches" do + it 'sync updated protected branches' do stub_fetch_remote(project, remote_name: remote_name, ssh_auth: remote_mirror) protected_branch = create_protected_branch(project) remote_mirror.only_protected_branches = true @@ -61,7 +103,7 @@ describe Projects::UpdateRemoteMirrorService do .to receive(:update_repository) .with(only_branches_matching: [protected_branch.name]) - service.execute(remote_mirror) + execute! end def create_protected_branch(project) diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index 82010dd283c..31bd0f0f836 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -369,9 +369,28 @@ describe Projects::UpdateService do end end + context 'when updating #emails_disabled' do + it 'updates the attribute for the project owner' do + expect { update_project(project, user, emails_disabled: true) } + .to change { project.emails_disabled } + .to(true) + end + + it 'does not update when not project owner' do + maintainer = create(:user) + project.add_user(maintainer, :maintainer) + + expect { update_project(project, maintainer, emails_disabled: true) } + .not_to change { project.emails_disabled } + end + end + context 'with external authorization enabled' do before do enable_external_authorization_service_check + + allow(::Gitlab::ExternalAuthorization) + .to receive(:access_allowed?).with(user, 'default_label', project.full_path).and_call_original end it 'does not save the project with an error if the service denies access' do @@ -402,8 +421,7 @@ describe Projects::UpdateService do end it 'does not check the label when it does not change' do - expect(::Gitlab::ExternalAuthorization) - .not_to receive(:access_allowed?) + expect(::Gitlab::ExternalAuthorization).to receive(:access_allowed?).once update_project(project, user, { name: 'New name' }) end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index bcc133790d1..bd504f1553b 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -48,6 +48,9 @@ Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f } quality_level = Quality::TestLevel.new RSpec.configure do |config| + config.filter_run focus: true + config.run_all_when_everything_filtered = true + config.use_transactional_fixtures = true config.use_instantiated_fixtures = false config.fixture_path = Rails.root diff --git a/spec/support/helpers/email_helpers.rb b/spec/support/helpers/email_helpers.rb index 83ba654fab3..024340310a1 100644 --- a/spec/support/helpers/email_helpers.rb +++ b/spec/support/helpers/email_helpers.rb @@ -31,6 +31,10 @@ module EmailHelpers expect(ActionMailer::Base.deliveries).to be_empty end + def should_email_anyone + expect(ActionMailer::Base.deliveries).not_to be_empty + end + def email_recipients(kind: :to) ActionMailer::Base.deliveries.flat_map(&kind) end diff --git a/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb b/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb new file mode 100644 index 00000000000..76d82649c5f --- /dev/null +++ b/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +shared_examples_for 'multiple issue boards' do + dropdown_selector = '.js-boards-selector .dropdown-menu' + + context 'authorized user' do + before do + parent.add_maintainer(user) + + login_as(user) + + visit boards_path + wait_for_requests + end + + it 'shows current board name' do + page.within('.boards-switcher') do + expect(page).to have_content(board.name) + end + end + + it 'shows a list of boards' do + click_button board.name + + page.within(dropdown_selector) do + expect(page).to have_content(board.name) + expect(page).to have_content(board2.name) + end + end + + it 'switches current board' do + click_button board.name + + page.within(dropdown_selector) do + click_link board2.name + end + + wait_for_requests + + page.within('.boards-switcher') do + expect(page).to have_content(board2.name) + end + end + + it 'creates new board without detailed configuration' do + click_button board.name + + page.within(dropdown_selector) do + click_button 'Create new board' + end + + fill_in 'board-new-name', with: 'This is a new board' + click_button 'Create board' + wait_for_requests + + expect(page).to have_button('This is a new board') + end + + it 'deletes board' do + click_button board.name + + wait_for_requests + + page.within(dropdown_selector) do + click_button 'Delete board' + end + + expect(page).to have_content('Are you sure you want to delete this board?') + click_button 'Delete' + + click_button board2.name + page.within(dropdown_selector) do + expect(page).not_to have_content(board.name) + expect(page).to have_content(board2.name) + end + end + + it 'adds a list to the none default board' do + click_button board.name + + page.within(dropdown_selector) do + click_link board2.name + end + + wait_for_requests + + page.within('.boards-switcher') do + expect(page).to have_content(board2.name) + end + + click_button 'Add list' + + wait_for_requests + + page.within '.dropdown-menu-issues-board-new' do + click_link planning.title + end + + wait_for_requests + + expect(page).to have_selector('.board', count: 3) + + click_button board2.name + + page.within(dropdown_selector) do + click_link board.name + end + + wait_for_requests + + expect(page).to have_selector('.board', count: 2) + end + + it 'maintains sidebar state over board switch' do + assert_boards_nav_active + + find('.boards-switcher').click + wait_for_requests + click_link board2.name + + assert_boards_nav_active + end + end + + context 'unauthorized user' do + before do + visit boards_path + wait_for_requests + end + + it 'does not show action links' do + click_button board.name + + page.within(dropdown_selector) do + expect(page).not_to have_content('Create new board') + expect(page).not_to have_content('Delete board') + end + end + end + + def assert_boards_nav_active + expect(find('.nav-sidebar .active .active')).to have_selector('a', text: 'Boards') + end +end diff --git a/spec/support/shared_examples/chat_slash_commands_shared_examples.rb b/spec/support/shared_examples/chat_slash_commands_shared_examples.rb index 82975027e5b..dcc92dda950 100644 --- a/spec/support/shared_examples/chat_slash_commands_shared_examples.rb +++ b/spec/support/shared_examples/chat_slash_commands_shared_examples.rb @@ -93,6 +93,19 @@ RSpec.shared_examples 'chat slash commands service' do subject.trigger(params) end + + context 'when user is blocked' do + before do + chat_name.user.block + end + + it 'blocks command execution' do + expect_any_instance_of(Gitlab::SlashCommands::Command).not_to receive(:execute) + + result = subject.trigger(params) + expect(result).to include(text: /^Whoops! This action is not allowed/) + end + end end end end diff --git a/spec/support/shared_examples/services/notification_service_shared_examples.rb b/spec/support/shared_examples/services/notification_service_shared_examples.rb new file mode 100644 index 00000000000..dd338ea47c7 --- /dev/null +++ b/spec/support/shared_examples/services/notification_service_shared_examples.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# Note that we actually update the attribute on the target_project/group, rather than +# using `allow`. This is because there are some specs where, based on how the notification +# is done, using an `allow` doesn't change the correct object. +shared_examples 'project emails are disabled' do + let(:target_project) { notification_target.is_a?(Project) ? notification_target : notification_target.project } + + before do + reset_delivered_emails! + target_project.clear_memoization(:emails_disabled) + end + + it 'sends no emails with project emails disabled' do + target_project.update_attribute(:emails_disabled, true) + + notification_trigger + + should_not_email_anyone + end + + it 'sends emails to someone' do + target_project.update_attribute(:emails_disabled, false) + + notification_trigger + + should_email_anyone + end +end + +shared_examples 'group emails are disabled' do + let(:target_group) { notification_target.is_a?(Group) ? notification_target : notification_target.project.group } + + before do + reset_delivered_emails! + target_group.clear_memoization(:emails_disabled) + end + + it 'sends no emails with group emails disabled' do + target_group.update_attribute(:emails_disabled, true) + + notification_trigger + + should_not_email_anyone + end + + it 'sends emails to someone' do + target_group.update_attribute(:emails_disabled, false) + + notification_trigger + + should_email_anyone + end +end diff --git a/spec/tasks/gitlab/update_templates_rake_spec.rb b/spec/tasks/gitlab/update_templates_rake_spec.rb index 7b17549b8c7..14b4ad5e3d8 100644 --- a/spec/tasks/gitlab/update_templates_rake_spec.rb +++ b/spec/tasks/gitlab/update_templates_rake_spec.rb @@ -8,9 +8,18 @@ describe 'gitlab:update_project_templates rake task' do before do Rake.application.rake_require 'tasks/gitlab/update_templates' create(:admin) + allow(Gitlab::ProjectTemplate) .to receive(:archive_directory) .and_return(Pathname.new(tmpdir)) + + # Gitlab::HTTP resolves the domain to an IP prior to WebMock taking effect, hence the wildcard + stub_request(:get, %r{^https://.*/api/v4/projects/gitlab-org%2Fproject-templates%2Frails/repository/commits\?page=1&per_page=1}) + .to_return( + status: 200, + body: [{ id: '67812735b83cb42710f22dc98d73d42c8bf4d907' }].to_json, + headers: { 'Content-Type' => 'application/json' } + ) end after do diff --git a/spec/views/layouts/_head.html.haml_spec.rb b/spec/views/layouts/_head.html.haml_spec.rb index cbb4199954a..70cdc08b4b6 100644 --- a/spec/views/layouts/_head.html.haml_spec.rb +++ b/spec/views/layouts/_head.html.haml_spec.rb @@ -70,6 +70,23 @@ describe 'layouts/_head' do expect(rendered).to match('<link rel="stylesheet" media="all" href="/stylesheets/highlight/themes/solarised-light.css" />') end + context 'when an asset_host is set and snowplow url is set' do + let(:asset_host) { 'http://test.host' } + + before do + allow(ActionController::Base).to receive(:asset_host).and_return(asset_host) + allow(Gitlab::CurrentSettings).to receive(:snowplow_enabled?).and_return(true) + allow(Gitlab::CurrentSettings).to receive(:snowplow_collector_hostname).and_return('www.snow.plow') + end + + it 'add a snowplow script tag with asset host' do + render + expect(rendered).to match('http://test.host/assets/snowplow/') + expect(rendered).to match('window.snowplow') + expect(rendered).to match('www.snow.plow') + end + end + def stub_helper_with_safe_string(method) allow_any_instance_of(PageLayoutHelper).to receive(method) .and_return(%q{foo" http-equiv="refresh}.html_safe) diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb index 081d95d4d79..c8a0c22b0e8 100644 --- a/spec/workers/post_receive_spec.rb +++ b/spec/workers/post_receive_spec.rb @@ -14,6 +14,10 @@ describe PostReceive do create(:project, :repository, auto_cancel_pending_pipelines: 'disabled') end + def perform(changes: base64_changes) + described_class.new.perform(gl_repository, key_id, changes) + end + context "as a sidekiq worker" do it "responds to #perform" do expect(described_class.new).to respond_to(:perform) @@ -28,18 +32,41 @@ describe PostReceive do it "returns false and logs an error" do expect(Gitlab::GitLogger).to receive(:error).with("POST-RECEIVE: #{error_message}") - expect(described_class.new.perform(gl_repository, key_id, base64_changes)).to be(false) + expect(perform).to be(false) end end describe "#process_project_changes" do + context 'with an empty project' do + let(:empty_project) { create(:project, :empty_repo) } + let(:changes) { "123456 789012 refs/heads/tést1\n" } + + before do + allow_any_instance_of(Gitlab::GitPostReceive).to receive(:identify).and_return(empty_project.owner) + allow(Gitlab::GlRepository).to receive(:parse).and_return([empty_project, Gitlab::GlRepository::PROJECT]) + end + + it 'expire the status cache' do + expect(empty_project.repository).to receive(:expire_status_cache) + + perform + end + + it 'schedules a cache update for commit count and size' do + expect(ProjectCacheWorker).to receive(:perform_async) + .with(empty_project.id, [], [:repository_size, :commit_count], true) + + perform + end + end + context 'empty changes' do it "does not call any PushService but runs after project hooks" do expect(Git::BranchPushService).not_to receive(:new) expect(Git::TagPushService).not_to receive(:new) expect_next_instance_of(SystemHooksService) { |service| expect(service).to receive(:execute_hooks) } - described_class.new.perform(gl_repository, key_id, "") + perform(changes: "") end end @@ -50,7 +77,7 @@ describe PostReceive do expect(Git::BranchPushService).not_to receive(:new) expect(Git::TagPushService).not_to receive(:new) - expect(described_class.new.perform(gl_repository, key_id, base64_changes)).to be false + expect(perform).to be false end end @@ -63,15 +90,22 @@ describe PostReceive do context "branches" do let(:changes) do <<~EOF - '123456 789012 refs/heads/tést1' - '123456 789012 refs/heads/tést2' + 123456 789012 refs/heads/tést1 + 123456 789012 refs/heads/tést2 EOF end it 'expires the branches cache' do expect(project.repository).to receive(:expire_branches_cache).once - described_class.new.perform(gl_repository, key_id, base64_changes) + perform + end + + it 'expires the status cache' do + expect(project).to receive(:empty_repo?).and_return(true) + expect(project.repository).to receive(:expire_status_cache) + + perform end it 'calls Git::BranchPushService' do @@ -81,27 +115,80 @@ describe PostReceive do expect(Git::TagPushService).not_to receive(:new) - described_class.new.perform(gl_repository, key_id, base64_changes) + perform + end + + it 'schedules a cache update for repository size only' do + expect(ProjectCacheWorker).to receive(:perform_async) + .with(project.id, [], [:repository_size], true) + + perform + end + + context 'with a default branch' do + let(:changes) do + <<~EOF + 123456 789012 refs/heads/tést1 + 123456 789012 refs/heads/tést2 + 678912 123455 refs/heads/#{project.default_branch} + EOF + end + + it 'schedules a cache update for commit count and size' do + expect(ProjectCacheWorker).to receive(:perform_async) + .with(project.id, [], [:repository_size, :commit_count], true) + + perform + end end end - context 'tags' do - let(:changes) { '123456 789012 refs/tags/tag' } + context "tags" do + let(:changes) do + <<~EOF + 654321 210987 refs/tags/tag1 + 654322 210986 refs/tags/tag2 + 654323 210985 refs/tags/tag3 + 654324 210984 refs/tags/tag4 + 654325 210983 refs/tags/tag5 + EOF + end + + before do + expect(Gitlab::GlRepository).to receive(:parse).and_return([project, Gitlab::GlRepository::PROJECT]) + end it 'does not expire branches cache' do expect(project.repository).not_to receive(:expire_branches_cache) - described_class.new.perform(gl_repository, key_id, base64_changes) + perform + end + + it "only invalidates tags once" do + expect(project.repository).to receive(:repository_event).exactly(5).times.with(:push_tag).and_call_original + expect(project.repository).to receive(:expire_caches_for_tags).once.and_call_original + expect(project.repository).to receive(:expire_tags_cache).once.and_call_original + + perform end - it 'calls Git::TagPushService' do - expect_next_instance_of(Git::TagPushService) do |service| + it "calls Git::TagPushService" do + expect(Git::BranchPushService).not_to receive(:new) + + expect_any_instance_of(Git::TagPushService) do |service| expect(service).to receive(:execute).and_return(true) end expect(Git::BranchPushService).not_to receive(:new) - described_class.new.perform(gl_repository, key_id, base64_changes) + perform + end + + it 'schedules a single ProjectCacheWorker update' do + expect(ProjectCacheWorker).to receive(:perform_async) + .with(project.id, [], [:repository_size], true) + + perform end end @@ -112,7 +199,7 @@ describe PostReceive do expect(Git::BranchPushService).not_to receive(:new) expect(Git::TagPushService).not_to receive(:new) - described_class.new.perform(gl_repository, key_id, base64_changes) + perform end end @@ -129,7 +216,7 @@ describe PostReceive do let(:changes_count) { changes.lines.count } - subject { described_class.new.perform(gl_repository, key_id, base64_changes) } + subject { perform } context "with valid .gitlab-ci.yml" do before do @@ -198,7 +285,13 @@ describe PostReceive do it 'calls SystemHooksService' do expect_any_instance_of(SystemHooksService).to receive(:execute_hooks).with(fake_hook_data, :repository_update_hooks).and_return(true) - described_class.new.perform(gl_repository, key_id, base64_changes) + perform + end + + it 'increments the usage data counter of pushes event' do + counter = Gitlab::UsageDataCounters::SourceCodeCounter + + expect { perform }.to change { counter.read(:pushes) }.by(1) end end end @@ -215,7 +308,7 @@ describe PostReceive do # a second to ensure we see changes. Timecop.freeze(1.second.from_now) do expect do - described_class.new.perform(gl_repository, key_id, base64_changes) + perform project.reload end.to change(project, :last_activity_at) .and change(project, :last_repository_updated_at) @@ -226,7 +319,8 @@ describe PostReceive do context "webhook" do it "fetches the correct project" do expect(Project).to receive(:find_by).with(id: project.id.to_s) - described_class.new.perform(gl_repository, key_id, base64_changes) + + perform end it "does not run if the author is not in the project" do @@ -236,16 +330,18 @@ describe PostReceive do expect(project).not_to receive(:execute_hooks) - expect(described_class.new.perform(gl_repository, key_id, base64_changes)).to be_falsey + expect(perform).to be_falsey end it "asks the project to trigger all hooks" do + create(:project_hook, push_events: true, tag_push_events: true, project: project) + create(:custom_issue_tracker_service, push_events: true, merge_requests_events: false, project: project) allow(Project).to receive(:find_by).and_return(project) expect(project).to receive(:execute_hooks).twice expect(project).to receive(:execute_services).twice - described_class.new.perform(gl_repository, key_id, base64_changes) + perform end it "enqueues a UpdateMergeRequestsWorker job" do @@ -253,7 +349,7 @@ describe PostReceive do expect(UpdateMergeRequestsWorker).to receive(:perform_async).with(project.id, project.owner.id, any_args) - described_class.new.perform(gl_repository, key_id, base64_changes) + perform end end end diff --git a/spec/workers/project_cache_worker_spec.rb b/spec/workers/project_cache_worker_spec.rb index edc55920b8e..7f3c4881b89 100644 --- a/spec/workers/project_cache_worker_spec.rb +++ b/spec/workers/project_cache_worker_spec.rb @@ -49,6 +49,16 @@ describe ProjectCacheWorker do worker.perform(project.id, %w(readme)) end + context 'with statistics disabled' do + let(:statistics) { [] } + + it 'does not update the project statistics' do + expect(worker).not_to receive(:update_statistics) + + worker.perform(project.id, [], [], false) + end + end + context 'with statistics' do let(:statistics) { %w(repository_size) } diff --git a/spec/workers/repository_update_remote_mirror_worker_spec.rb b/spec/workers/repository_update_remote_mirror_worker_spec.rb index 4de51ecb3e9..66d517332ba 100644 --- a/spec/workers/repository_update_remote_mirror_worker_spec.rb +++ b/spec/workers/repository_update_remote_mirror_worker_spec.rb @@ -2,99 +2,70 @@ require 'rails_helper' -describe RepositoryUpdateRemoteMirrorWorker do +describe RepositoryUpdateRemoteMirrorWorker, :clean_gitlab_redis_shared_state do subject { described_class.new } - let(:remote_mirror) { create(:project, :repository, :remote_mirror).remote_mirrors.first } + let(:remote_mirror) { create(:remote_mirror) } let(:scheduled_time) { Time.now - 5.minutes } around do |example| Timecop.freeze(Time.now) { example.run } end - describe '#perform' do - context 'with status none' do - before do - remote_mirror.update(update_status: 'none') - end - - it 'sets status as finished when update remote mirror service executes successfully' do - expect_any_instance_of(Projects::UpdateRemoteMirrorService).to receive(:execute).with(remote_mirror).and_return(status: :success) - - expect { subject.perform(remote_mirror.id, Time.now) }.to change { remote_mirror.reload.update_status }.to('finished') - end - - it 'resets the notification flag upon success' do - expect_any_instance_of(Projects::UpdateRemoteMirrorService).to receive(:execute).with(remote_mirror).and_return(status: :success) - remote_mirror.update_column(:error_notification_sent, true) - - expect { subject.perform(remote_mirror.id, Time.now) }.to change { remote_mirror.reload.error_notification_sent }.to(false) - end - - it 'sets status as failed when update remote mirror service executes with errors' do - error_message = 'fail!' - - expect_next_instance_of(Projects::UpdateRemoteMirrorService) do |service| - expect(service).to receive(:execute).with(remote_mirror).and_return(status: :error, message: error_message) - end + def expect_mirror_service_to_return(mirror, result, tries = 0) + expect_next_instance_of(Projects::UpdateRemoteMirrorService) do |service| + expect(service).to receive(:execute).with(mirror, tries).and_return(result) + end + end - # Mock the finder so that it returns an object we can set expectations on - expect_next_instance_of(RemoteMirrorFinder) do |finder| - expect(finder).to receive(:execute).and_return(remote_mirror) - end - expect(remote_mirror).to receive(:mark_as_failed).with(error_message) + describe '#perform' do + it 'calls out to the service to perform the update' do + expect_mirror_service_to_return(remote_mirror, status: :success) - expect do - subject.perform(remote_mirror.id, Time.now) - end.to raise_error(RepositoryUpdateRemoteMirrorWorker::UpdateError, error_message) - end + subject.perform(remote_mirror.id, scheduled_time) + end - it 'does nothing if last_update_started_at is higher than the time the job was scheduled in' do - remote_mirror.update(last_update_started_at: Time.now) + it 'does not do anything if the mirror was already updated' do + remote_mirror.update(last_update_started_at: Time.now, update_status: :finished) - expect_any_instance_of(RemoteMirror).to receive(:updated_since?).with(scheduled_time).and_return(true) - expect_any_instance_of(Projects::UpdateRemoteMirrorService).not_to receive(:execute).with(remote_mirror) + expect(Projects::UpdateRemoteMirrorService).not_to receive(:new) - expect(subject.perform(remote_mirror.id, scheduled_time)).to be_nil - end + subject.perform(remote_mirror.id, scheduled_time) end - context 'with unexpected error' do - it 'marks mirror as failed' do - allow_any_instance_of(Projects::UpdateRemoteMirrorService).to receive(:execute).with(remote_mirror).and_raise(RuntimeError) + it 'schedules a retry when the mirror is marked for retrying' do + remote_mirror = create(:remote_mirror, update_status: :to_retry) + expect_mirror_service_to_return(remote_mirror, status: :error, message: 'Retry!') - expect do - subject.perform(remote_mirror.id, Time.now) - end.to raise_error(RepositoryUpdateRemoteMirrorWorker::UpdateError) - expect(remote_mirror.reload.update_status).to eq('failed') - end - end + expect(described_class) + .to receive(:perform_in) + .with(remote_mirror.backoff_delay, remote_mirror.id, scheduled_time, 1) - context 'with another worker already running' do - before do - remote_mirror.update(update_status: 'started') - end - - it 'raises RemoteMirrorUpdateAlreadyInProgressError' do - expect do - subject.perform(remote_mirror.id, Time.now) - end.to raise_error(RepositoryUpdateRemoteMirrorWorker::UpdateAlreadyInProgressError) - end + subject.perform(remote_mirror.id, scheduled_time) end - context 'with status failed' do - before do - remote_mirror.update(update_status: 'failed') + it 'clears the lease if there was an unexpected exception' do + expect_next_instance_of(Projects::UpdateRemoteMirrorService) do |service| + expect(service).to receive(:execute).with(remote_mirror, 1).and_raise('Unexpected!') end + expect { subject.perform(remote_mirror.id, Time.now, 1) }.to raise_error('Unexpected!') - it 'sets status as finished if last_update_started_at is higher than the time the job was scheduled in' do - remote_mirror.update(last_update_started_at: Time.now) + lease = Gitlab::ExclusiveLease.new("#{described_class.name}:#{remote_mirror.id}", timeout: 1.second) - expect_any_instance_of(RemoteMirror).to receive(:updated_since?).with(scheduled_time).and_return(false) - expect_any_instance_of(Projects::UpdateRemoteMirrorService).to receive(:execute).with(remote_mirror).and_return(status: :success) + expect(lease.try_obtain).not_to be_nil + end - expect { subject.perform(remote_mirror.id, scheduled_time) }.to change { remote_mirror.reload.update_status }.to('finished') - end + it 'retries 3 times for the worker to finish before rescheduling' do + expect(subject).to receive(:in_lock) + .with("#{described_class.name}:#{remote_mirror.id}", + retries: 3, + ttl: remote_mirror.max_runtime, + sleep_sec: described_class::LOCK_WAIT_TIME) + .and_raise(Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError) + expect(described_class).to receive(:perform_in) + .with(remote_mirror.backoff_delay, remote_mirror.id, scheduled_time, 0) + + subject.perform(remote_mirror.id, scheduled_time) end end end diff --git a/yarn.lock b/yarn.lock index ed1f06523c0..a295039ec54 100644 --- a/yarn.lock +++ b/yarn.lock @@ -998,10 +998,10 @@ dependencies: vue-eslint-parser "^6.0.4" -"@gitlab/svgs@^1.67.0": - version "1.67.0" - resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.67.0.tgz#c7b94eca13b99fd3aaa737fb6dcc0abc41d3c579" - integrity sha512-hJOmWEs6RkjzyKkb1vc9wwKGZIBIP0coHkxu/KgOoxhBVudpGk4CH7xJ6UuB2TKpb0SEh5CC1CzRZfBYaFhsaA== +"@gitlab/svgs@^1.68.0": + version "1.68.0" + resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.68.0.tgz#d631bd84ea7907592240d8417e82ba66d6a54c0c" + integrity sha512-3JmIq0bHg4InjLooM+kQFPfg3d7B1Pye67pN9+12kZXIa0nRGuwKEq3WSbcS+ACdg5jcVPNPYqStItEO4teHdw== "@gitlab/ui@5.15.0": version "5.15.0" |