diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-04-14 15:09:44 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-04-14 15:09:44 +0000 |
commit | 874ead9c3a50de4c4ca4551eaf5b7eb976d26b50 (patch) | |
tree | 637ee9f2da5e251bc08ebf3e972209d51966bf7c | |
parent | 2e4c4055181eec9186458dd5dd3219c937032ec7 (diff) | |
download | gitlab-ce-874ead9c3a50de4c4ca4551eaf5b7eb976d26b50.tar.gz |
Add latest changes from gitlab-org/gitlab@master
119 files changed, 1482 insertions, 553 deletions
diff --git a/app/assets/javascripts/badges/components/badge_list.vue b/app/assets/javascripts/badges/components/badge_list.vue index d2767dd6c64..04c2d4a7493 100644 --- a/app/assets/javascripts/badges/components/badge_list.vue +++ b/app/assets/javascripts/badges/components/badge_list.vue @@ -28,7 +28,7 @@ export default { {{ s__('Badges|Your badges') }} <span v-show="!isLoading" class="badge badge-pill">{{ badges.length }}</span> </div> - <gl-loading-icon v-show="isLoading" :size="2" class="card-body" /> + <gl-loading-icon v-show="isLoading" size="lg" class="card-body" /> <div v-if="hasNoBadges" class="card-body"> <span v-if="isGroupBadge">{{ s__('Badges|This group has no badges') }}</span> <span v-else>{{ s__('Badges|This project has no badges') }}</span> diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue index 19516a13d15..3de1b2f0707 100644 --- a/app/assets/javascripts/contributors/components/contributors.vue +++ b/app/assets/javascripts/contributors/components/contributors.vue @@ -197,7 +197,7 @@ export default { <template> <div> <div v-if="loading" class="contributors-loader text-center"> - <gl-loading-icon :inline="true" :size="4" /> + <gl-loading-icon :inline="true" size="xl" /> </div> <div v-else-if="showChart" class="contributors-charts"> diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index 048f3a2485c..5505704f430 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -119,7 +119,7 @@ export default { <gl-loading-icon v-if="isLoading && !hasKeys" :label="s__('DeployKeys|Loading deploy keys')" - :size="2" + size="lg" /> <template v-else-if="hasKeys"> <div class="top-area scrolling-tabs-container inner-page-scroll-tabs"> diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 305d860a692..335c668474e 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -58,12 +58,6 @@ export default { required: true, }, - shouldShowAutoStopDate: { - type: Boolean, - required: false, - default: false, - }, - tableData: { type: Object, required: true, @@ -638,12 +632,7 @@ export default { </span> </div> - <div - v-if="!isFolder && shouldShowAutoStopDate" - class="table-section" - :class="tableData.autoStop.spacing" - role="gridcell" - > + <div v-if="!isFolder" class="table-section" :class="tableData.autoStop.spacing" role="gridcell"> <div role="rowheader" class="table-mobile-header">{{ tableData.autoStop.title }}</div> <span v-if="canShowAutoStopDate" @@ -662,10 +651,7 @@ export default { role="gridcell" > <div class="btn-group table-action-buttons" role="group"> - <pin-component - v-if="canShowAutoStopDate && shouldShowAutoStopDate" - :auto-stop-url="autoStopUrl" - /> + <pin-component v-if="canShowAutoStopDate" :auto-stop-url="autoStopUrl" /> <external-url-component v-if="externalURL && canReadEnvironment" diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 01a00e03814..89e40faa23e 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -6,7 +6,6 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { flow, reverse, sortBy } from 'lodash/fp'; import environmentTableMixin from 'ee_else_ce/environments/mixins/environments_table_mixin'; import { s__ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import EnvironmentItem from './environment_item.vue'; export default { @@ -17,7 +16,7 @@ export default { CanaryDeploymentCallout: () => import('ee_component/environments/components/canary_deployment_callout.vue'), }, - mixins: [environmentTableMixin, glFeatureFlagsMixin()], + mixins: [environmentTableMixin], props: { environments: { type: Array, @@ -43,9 +42,6 @@ export default { : env, ); }, - shouldShowAutoStopDate() { - return this.glFeatures.autoStopEnvironments; - }, tableData() { return { // percent spacing for cols, should add up to 100 @@ -74,7 +70,7 @@ export default { spacing: 'section-5', }, actions: { - spacing: this.shouldShowAutoStopDate ? 'section-25' : 'section-30', + spacing: 'section-25', }, }; }, @@ -131,12 +127,7 @@ export default { <div class="table-section" :class="tableData.date.spacing" role="columnheader"> {{ tableData.date.title }} </div> - <div - v-if="shouldShowAutoStopDate" - class="table-section" - :class="tableData.autoStop.spacing" - role="columnheader" - > + <div class="table-section" :class="tableData.autoStop.spacing" role="columnheader"> {{ tableData.autoStop.title }} </div> </div> @@ -146,7 +137,6 @@ export default { :key="`environment-item-${i}`" :model="model" :can-read-environment="canReadEnvironment" - :should-show-auto-stop-date="shouldShowAutoStopDate" :table-data="tableData" /> diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index a8103c80da0..148edfe3a51 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -225,7 +225,7 @@ export default { <template> <div> <div v-if="errorLoading" class="py-3"> - <gl-loading-icon :size="3" /> + <gl-loading-icon size="lg" /> </div> <div v-else-if="error" class="error-details"> <gl-alert v-if="isAlertVisible" @dismiss="isAlertVisible = false"> @@ -405,7 +405,7 @@ export default { </ul> <div v-if="loadingStacktrace" class="py-3"> - <gl-loading-icon :size="3" /> + <gl-loading-icon size="lg" /> </div> <template v-else-if="showStacktrace"> diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue index 2ffecce0a56..1f1776a5487 100644 --- a/app/assets/javascripts/frequent_items/components/app.vue +++ b/app/assets/javascripts/frequent_items/components/app.vue @@ -107,7 +107,7 @@ export default { <gl-loading-icon v-if="isLoadingItems" :label="translations.loadingMessage" - :size="2" + size="lg" class="loading-animation prepend-top-20" /> <div v-if="!isLoadingItems && !hasSearchQuery" class="section-header"> diff --git a/app/assets/javascripts/ide/components/branches/search_list.vue b/app/assets/javascripts/ide/components/branches/search_list.vue index 76821bcd986..dd2d726d525 100644 --- a/app/assets/javascripts/ide/components/branches/search_list.vue +++ b/app/assets/javascripts/ide/components/branches/search_list.vue @@ -72,7 +72,7 @@ export default { <div class="dropdown-content ide-merge-requests-dropdown-content d-flex"> <gl-loading-icon v-if="isLoading" - :size="2" + size="lg" class="mt-3 mb-3 align-self-center ml-auto mr-auto" /> <ul v-else class="mb-0 w-100"> diff --git a/app/assets/javascripts/ide/components/file_templates/dropdown.vue b/app/assets/javascripts/ide/components/file_templates/dropdown.vue index 35e5f9bcf69..d80662f6ae1 100644 --- a/app/assets/javascripts/ide/components/file_templates/dropdown.vue +++ b/app/assets/javascripts/ide/components/file_templates/dropdown.vue @@ -88,7 +88,7 @@ export default { <i aria-hidden="true" class="fa fa-search dropdown-input-search"></i> </div> <div class="dropdown-content"> - <gl-loading-icon v-if="showLoading" :size="2" /> + <gl-loading-icon v-if="showLoading" size="lg" /> <ul v-else> <li v-for="(item, index) in outputData" :key="index"> <button type="button" @click="clickItem(item)">{{ item.name }}</button> diff --git a/app/assets/javascripts/ide/components/jobs/list.vue b/app/assets/javascripts/ide/components/jobs/list.vue index 2cb5050c3f0..b97b7289886 100644 --- a/app/assets/javascripts/ide/components/jobs/list.vue +++ b/app/assets/javascripts/ide/components/jobs/list.vue @@ -26,7 +26,7 @@ export default { <template> <div> - <gl-loading-icon v-if="loading && !stages.length" :size="2" class="prepend-top-default" /> + <gl-loading-icon v-if="loading && !stages.length" size="lg" class="prepend-top-default" /> <template v-else> <stage v-for="stage in stages" diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue index 15c08988977..bf2a33be653 100644 --- a/app/assets/javascripts/ide/components/merge_requests/list.vue +++ b/app/assets/javascripts/ide/components/merge_requests/list.vue @@ -90,7 +90,7 @@ export default { <div class="dropdown-content ide-merge-requests-dropdown-content d-flex"> <gl-loading-icon v-if="isLoading" - :size="2" + size="lg" class="mt-3 mb-3 align-self-center ml-auto mr-auto" /> <template v-else> diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index 343b0b6e90c..d3e5add2e83 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -56,7 +56,7 @@ export default { <template> <div class="ide-pipeline"> - <gl-loading-icon v-if="showLoadingIcon" :size="2" class="prepend-top-default" /> + <gl-loading-icon v-if="showLoadingIcon" size="lg" class="prepend-top-default" /> <template v-else-if="hasLoadedPipeline"> <header v-if="latestPipeline" class="ide-tree-header ide-pipeline-header"> <ci-icon :status="latestPipeline.details.status" :size="24" class="d-flex" /> diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue index 86a773499bc..3852f2fdfa4 100644 --- a/app/assets/javascripts/ide/components/preview/clientside.vue +++ b/app/assets/javascripts/ide/components/preview/clientside.vue @@ -176,6 +176,6 @@ export default { {{ s__('IDE|Get started with Live Preview') }} </a> </div> - <gl-loading-icon v-else :size="2" class="align-self-center mt-auto mb-auto" /> + <gl-loading-icon v-else size="lg" class="align-self-center mt-auto mb-auto" /> </div> </template> diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 9b0ee40a30a..4a48852159a 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -910,3 +910,18 @@ export const setCookie = (name, value) => Cookies.set(name, value, { expires: 36 export const getCookie = name => Cookies.get(name); export const removeCookie = name => Cookies.remove(name); + +/** + * Returns the status of a feature flag. + * Currently, there is no way to access feature + * flags in Vuex other than directly tapping into + * window.gon. + * + * This should only be used on Vuex. If feature flags + * need to be accessed in Vue components consider + * using the Vue feature flag mixin. + * + * @param {String} flag Feature flag + * @returns {Boolean} on/off + */ +export const isFeatureFlagEnabled = flag => window.gon.features?.[flag]; diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index 24aa8480ce4..9041b01088c 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -55,6 +55,11 @@ export default { required: false, default: () => [], }, + annotations: { + type: Array, + required: false, + default: () => [], + }, projectPath: { type: String, required: false, @@ -143,6 +148,7 @@ export default { return (this.option.series || []).concat( generateAnnotationsSeries({ deployments: this.recentDeployments, + annotations: this.annotations, }), ); }, diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 15b17f01daf..4586ce70ad6 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -213,7 +213,6 @@ export default { 'dashboard', 'emptyState', 'showEmptyState', - 'deploymentData', 'useDashboardEndpoint', 'allDashboards', 'additionalPanelTypesEnabled', diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue index d1394bca447..676fc0cca64 100644 --- a/app/assets/javascripts/monitoring/components/panel_type.vue +++ b/app/assets/javascripts/monitoring/components/panel_type.vue @@ -89,6 +89,9 @@ export default { deploymentData(state) { return state[this.namespace].deploymentData; }, + annotations(state) { + return state[this.namespace].annotations; + }, projectPath(state) { return state[this.namespace].projectPath; }, @@ -310,6 +313,7 @@ export default { ref="timeChart" :graph-data="graphData" :deployment-data="deploymentData" + :annotations="annotations" :project-path="projectPath" :thresholds="getGraphAlertValues(graphData.metrics)" :group-id="groupId" diff --git a/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql b/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql new file mode 100644 index 00000000000..e2edaa707b2 --- /dev/null +++ b/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql @@ -0,0 +1,13 @@ +query getAnnotations($projectPath: ID!) { + environment(name: $environmentName) { + metricDashboard(id: $dashboardId) { + annotations: nodes { + id + description + from + to + panelId + } + } + } +} diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 06b99f572e7..5b2bd1f1493 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -6,8 +6,13 @@ import { convertToFixedRange } from '~/lib/utils/datetime_range'; import { gqClient, parseEnvironmentsResponse, removeLeadingSlash } from './utils'; import trackDashboardLoad from '../monitoring_tracking_helper'; import getEnvironments from '../queries/getEnvironments.query.graphql'; +import getAnnotations from '../queries/getAnnotations.query.graphql'; import statusCodes from '../../lib/utils/http_status'; -import { backOff, convertObjectPropsToCamelCase } from '../../lib/utils/common_utils'; +import { + backOff, + convertObjectPropsToCamelCase, + isFeatureFlagEnabled, +} from '../../lib/utils/common_utils'; import { s__, sprintf } from '../../locale'; import { PROMETHEUS_TIMEOUT, ENVIRONMENT_AVAILABLE_STATE } from '../constants'; @@ -80,6 +85,14 @@ export const setShowErrorBanner = ({ commit }, enabled) => { export const fetchData = ({ dispatch }) => { dispatch('fetchEnvironmentsData'); dispatch('fetchDashboard'); + /** + * Annotations data is not yet fetched. This will be + * ready after the BE piece is implemented. + * https://gitlab.com/gitlab-org/gitlab/-/issues/211330 + */ + if (isFeatureFlagEnabled('metrics_dashboard_annotations')) { + dispatch('fetchAnnotations'); + } }; // Metrics dashboard @@ -269,6 +282,40 @@ export const receiveEnvironmentsDataFailure = ({ commit }) => { commit(types.RECEIVE_ENVIRONMENTS_DATA_FAILURE); }; +export const fetchAnnotations = ({ state, dispatch }) => { + dispatch('requestAnnotations'); + + return gqClient + .mutate({ + mutation: getAnnotations, + variables: { + projectPath: removeLeadingSlash(state.projectPath), + dashboardId: state.currentDashboard, + environmentName: state.currentEnvironmentName, + }, + }) + .then(resp => resp.data?.project?.environment?.metricDashboard?.annotations) + .then(annotations => { + if (!annotations) { + createFlash(s__('Metrics|There was an error fetching annotations. Please try again.')); + } + + dispatch('receiveAnnotationsSuccess', annotations); + }) + .catch(err => { + Sentry.captureException(err); + dispatch('receiveAnnotationsFailure'); + createFlash(s__('Metrics|There was an error getting annotations information.')); + }); +}; + +// While this commit does not update the state it will +// eventually be useful to show a loading state +export const requestAnnotations = ({ commit }) => commit(types.REQUEST_ANNOTATIONS); +export const receiveAnnotationsSuccess = ({ commit }, data) => + commit(types.RECEIVE_ANNOTATIONS_SUCCESS, data); +export const receiveAnnotationsFailure = ({ commit }) => commit(types.RECEIVE_ANNOTATIONS_FAILURE); + // Dashboard manipulation /** diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index 9a3489d53d7..2f9955da1b1 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -3,6 +3,11 @@ export const REQUEST_METRICS_DASHBOARD = 'REQUEST_METRICS_DASHBOARD'; export const RECEIVE_METRICS_DASHBOARD_SUCCESS = 'RECEIVE_METRICS_DASHBOARD_SUCCESS'; export const RECEIVE_METRICS_DASHBOARD_FAILURE = 'RECEIVE_METRICS_DASHBOARD_FAILURE'; +// Annotations +export const REQUEST_ANNOTATIONS = 'REQUEST_ANNOTATIONS'; +export const RECEIVE_ANNOTATIONS_SUCCESS = 'RECEIVE_ANNOTATIONS_SUCCESS'; +export const RECEIVE_ANNOTATIONS_FAILURE = 'RECEIVE_ANNOTATIONS_FAILURE'; + // Git project deployments export const REQUEST_DEPLOYMENTS_DATA = 'REQUEST_DEPLOYMENTS_DATA'; export const RECEIVE_DEPLOYMENTS_DATA_SUCCESS = 'RECEIVE_DEPLOYMENTS_DATA_SUCCESS'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 38c1524d904..aa31b6642d7 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -93,6 +93,16 @@ export default { }, /** + * Annotations + */ + [types.RECEIVE_ANNOTATIONS_SUCCESS](state, annotations) { + state.annotations = annotations; + }, + [types.RECEIVE_ANNOTATIONS_FAILURE](state) { + state.annotations = []; + }, + + /** * Individual panel/metric results */ [types.REQUEST_METRIC_RESULT](state, { metricId }) { diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index 2b1907e8df7..e60510e747b 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -20,6 +20,7 @@ export default () => ({ allDashboards: [], // Other project data + annotations: [], deploymentData: [], environments: [], environmentsSearchTerm: '', diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index ef3f4d0e3f6..1ff5b662d18 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -135,7 +135,7 @@ export default { paddingRight: `${graphRightPadding}px`, }" > - <gl-loading-icon v-if="isLoading" class="m-auto" :size="3" /> + <gl-loading-icon v-if="isLoading" class="m-auto" size="lg" /> <pipeline-graph v-if="pipelineTypeUpstream" diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index 2a3d022c5cd..e7777d0d3af 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -108,7 +108,7 @@ export default { /> </ci-header> - <gl-loading-icon v-if="isLoading" :size="2" class="prepend-top-default append-bottom-default" /> + <gl-loading-icon v-if="isLoading" size="lg" class="prepend-top-default append-bottom-default" /> <gl-modal :modal-id="$options.DELETE_MODAL_ID" diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue index accd6bf71f4..d4f23697e09 100644 --- a/app/assets/javascripts/pipelines/components/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines.vue @@ -271,7 +271,7 @@ export default { <gl-loading-icon v-if="stateToRender === $options.stateMap.loading" :label="s__('Pipelines|Loading Pipelines')" - :size="3" + size="lg" class="prepend-top-20" /> diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue index f1106dc6ae9..571d305a50c 100644 --- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue +++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue @@ -94,7 +94,7 @@ export default { </script> <template> <div class="ci-status-link"> - <gl-loading-icon v-if="isLoading" :size="3" label="Loading pipeline status" /> + <gl-loading-icon v-if="isLoading" size="lg" label="Loading pipeline status" /> <a v-else :href="ciStatus.details_path"> <ci-icon v-tooltip diff --git a/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue b/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue index c90478db620..807f10bd9c6 100644 --- a/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue +++ b/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue @@ -36,7 +36,7 @@ export default { </div> </div> <div v-if="loadingStacktrace" class="card"> - <gl-loading-icon class="py-2" label="Fetching stack trace" :size="1" /> + <gl-loading-icon class="py-2" label="Fetching stack trace" size="sm" /> </div> <stacktrace v-else :entries="stacktrace" /> </div> diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index e06149f2bcb..2b1291ac70f 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -77,7 +77,7 @@ export default { <section id="serverless-functions" class="flex-grow"> <gl-loading-icon v-if="checkingInstalled" - :size="2" + size="lg" class="prepend-top-default append-bottom-default" /> @@ -97,7 +97,7 @@ export default { </template> <gl-loading-icon v-if="isLoading" - :size="2" + size="lg" class="prepend-top-default append-bottom-default js-functions-loader" /> </div> diff --git a/app/assets/javascripts/smart_interval.js b/app/assets/javascripts/smart_interval.js index 8ca590123ae..0e52d2d8010 100644 --- a/app/assets/javascripts/smart_interval.js +++ b/app/assets/javascripts/smart_interval.js @@ -33,7 +33,7 @@ export default class SmartInterval { this.state = { intervalId: null, currentInterval: this.cfg.startingInterval, - pageVisibility: 'visible', + pagevisibile: true, }; this.initInterval(); @@ -91,8 +91,10 @@ export default class SmartInterval { } destroy() { + document.removeEventListener('visibilitychange', this.onVisibilityChange); + window.removeEventListener('blur', this.onWindowVisibilityChange); + window.removeEventListener('focus', this.onWindowVisibilityChange); this.cancel(); - document.removeEventListener('visibilitychange', this.handleVisibilityChange); $(document) .off('visibilitychange') .off('beforeunload'); @@ -124,9 +126,21 @@ export default class SmartInterval { }); } + onWindowVisibilityChange(e) { + this.state.pagevisibile = e.type === 'focus'; + this.handleVisibilityChange(); + } + + onVisibilityChange(e) { + this.state.pagevisibile = e.target.visibilityState === 'visible'; + this.handleVisibilityChange(); + } + initVisibilityChangeHandling() { - // cancel interval when tab no longer shown (prevents cached pages from polling) - document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this)); + // cancel interval when tab or window is no longer shown (prevents cached pages from polling) + document.addEventListener('visibilitychange', this.onVisibilityChange.bind(this)); + window.addEventListener('blur', this.onWindowVisibilityChange.bind(this)); + window.addEventListener('focus', this.onWindowVisibilityChange.bind(this)); } initPageUnloadHandling() { @@ -135,8 +149,7 @@ export default class SmartInterval { $(document).on('beforeunload', () => this.cancel()); } - handleVisibilityChange(e) { - this.state.pageVisibility = e.target.visibilityState; + handleVisibilityChange() { const intervalAction = this.isPageVisible() ? this.onVisibilityVisible : this.onVisibilityHidden; @@ -166,7 +179,7 @@ export default class SmartInterval { } isPageVisible() { - return this.state.pageVisibility === 'visible'; + return this.state.pagevisibile; } stopTimer() { diff --git a/app/assets/javascripts/snippets/components/show.vue b/app/assets/javascripts/snippets/components/show.vue index e98f56d87f5..bc0034d397e 100644 --- a/app/assets/javascripts/snippets/components/show.vue +++ b/app/assets/javascripts/snippets/components/show.vue @@ -1,10 +1,11 @@ <script> -import GetSnippetQuery from '../queries/snippet.query.graphql'; import SnippetHeader from './snippet_header.vue'; import SnippetTitle from './snippet_title.vue'; import SnippetBlob from './snippet_blob_view.vue'; import { GlLoadingIcon } from '@gitlab/ui'; +import { getSnippetMixin } from '../mixins/snippets'; + export default { components: { SnippetHeader, @@ -12,33 +13,7 @@ export default { GlLoadingIcon, SnippetBlob, }, - apollo: { - snippet: { - query: GetSnippetQuery, - variables() { - return { - ids: this.snippetGid, - }; - }, - update: data => data.snippets.edges[0].node, - }, - }, - props: { - snippetGid: { - type: String, - required: true, - }, - }, - data() { - return { - snippet: {}, - }; - }, - computed: { - isLoading() { - return this.$apollo.queries.snippet.loading; - }, - }, + mixins: [getSnippetMixin], }; </script> <template> @@ -46,7 +21,7 @@ export default { <gl-loading-icon v-if="isLoading" :label="__('Loading snippet')" - :size="2" + size="lg" class="loading-animation prepend-top-20 append-bottom-20" /> <template v-else> diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue index ae6f451df18..44b4607e5a9 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue @@ -37,7 +37,7 @@ export default { <gl-loading-icon v-if="isLoading" :label="__('Loading snippet')" - :size="2" + size="lg" class="loading-animation prepend-top-20 append-bottom-20" /> <blob-content-edit diff --git a/app/assets/javascripts/snippets/mixins/snippets.js b/app/assets/javascripts/snippets/mixins/snippets.js new file mode 100644 index 00000000000..837c41cdf6b --- /dev/null +++ b/app/assets/javascripts/snippets/mixins/snippets.js @@ -0,0 +1,39 @@ +import GetSnippetQuery from '../queries/snippet.query.graphql'; + +export const getSnippetMixin = { + apollo: { + snippet: { + query: GetSnippetQuery, + variables() { + return { + ids: this.snippetGid, + }; + }, + update: data => data.snippets.edges[0]?.node, + result(res) { + if (this.onSnippetFetch) { + this.onSnippetFetch(res); + } + }, + }, + }, + props: { + snippetGid: { + type: String, + required: true, + }, + }, + data() { + return { + snippet: {}, + newSnippet: false, + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.snippet.loading; + }, + }, +}; + +export default () => {}; diff --git a/app/assets/javascripts/static_site_editor/components/static_site_editor.vue b/app/assets/javascripts/static_site_editor/components/static_site_editor.vue index e711510ba44..8deae2f2c8a 100644 --- a/app/assets/javascripts/static_site_editor/components/static_site_editor.vue +++ b/app/assets/javascripts/static_site_editor/components/static_site_editor.vue @@ -12,8 +12,8 @@ export default { Toolbar, }, computed: { - ...mapState(['content', 'isLoadingContent', 'isSavingChanges']), - ...mapGetters(['isContentLoaded', 'contentChanged']), + ...mapState(['content', 'isLoadingContent', 'isSavingChanges', 'isContentLoaded']), + ...mapGetters(['contentChanged']), }, mounted() { this.loadContent(); diff --git a/app/assets/javascripts/static_site_editor/store/getters.js b/app/assets/javascripts/static_site_editor/store/getters.js index 41256201c26..ebc68f8e9e6 100644 --- a/app/assets/javascripts/static_site_editor/store/getters.js +++ b/app/assets/javascripts/static_site_editor/store/getters.js @@ -1,2 +1,2 @@ -export const isContentLoaded = ({ originalContent }) => Boolean(originalContent); +// eslint-disable-next-line import/prefer-default-export export const contentChanged = ({ originalContent, content }) => originalContent !== content; diff --git a/app/assets/javascripts/static_site_editor/store/mutations.js b/app/assets/javascripts/static_site_editor/store/mutations.js index f98177bbc18..4727d04439c 100644 --- a/app/assets/javascripts/static_site_editor/store/mutations.js +++ b/app/assets/javascripts/static_site_editor/store/mutations.js @@ -6,6 +6,7 @@ export default { }, [types.RECEIVE_CONTENT_SUCCESS](state, { title, content }) { state.isLoadingContent = false; + state.isContentLoaded = true; state.title = title; state.content = content; state.originalContent = content; diff --git a/app/assets/javascripts/static_site_editor/store/state.js b/app/assets/javascripts/static_site_editor/store/state.js index 477ec540e02..d48cc8ed1a4 100644 --- a/app/assets/javascripts/static_site_editor/store/state.js +++ b/app/assets/javascripts/static_site_editor/store/state.js @@ -6,6 +6,8 @@ const createState = (initialState = {}) => ({ isLoadingContent: false, isSavingChanges: false, + isContentLoaded: false, + originalContent: '', content: '', title: '', diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 1bc28b15f74..05f73c4cdaf 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -214,8 +214,6 @@ export default { return new MRWidgetService(this.getServiceEndpoints(store)); }, checkStatus(cb, isRebased) { - if (document.visibilityState !== 'visible') return Promise.resolve(); - return this.service .checkStatus() .then(({ data }) => { @@ -238,10 +236,10 @@ export default { initPolling() { this.pollingInterval = new SmartInterval({ callback: this.checkStatus, - startingInterval: 10000, - maxInterval: 30000, - hiddenInterval: 120000, - incrementByFactorOf: 5000, + startingInterval: 10 * 1000, + maxInterval: 240 * 1000, + hiddenInterval: window.gon?.features?.widgetVisibilityPolling && 360 * 1000, + incrementByFactorOf: 2, }); }, initDeploymentsPolling() { @@ -253,10 +251,9 @@ export default { deploymentsPoll(callback) { return new SmartInterval({ callback, - startingInterval: 30000, - maxInterval: 120000, - hiddenInterval: 240000, - incrementByFactorOf: 15000, + startingInterval: 30 * 1000, + maxInterval: 240 * 1000, + incrementByFactorOf: 4, immediateExecution: true, }); }, diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue index 30a9633b6dc..fd45ac52647 100644 --- a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue @@ -80,7 +80,7 @@ export default { @input="onInput" /> <div class="d-flex flex-column"> - <gl-loading-icon v-if="showLoadingIndicator" :size="1" class="py-2 px-4" /> + <gl-loading-icon v-if="showLoadingIndicator" size="sm" class="py-2 px-4" /> <gl-infinite-scroll :max-list-height="402" :fetched-items="projectSearchResults.length" diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 210d488f5a3..16254c74ba4 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -219,6 +219,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController :domain_blacklist_file, :raw_blob_request_limit, :namespace_storage_size_limit, + :issues_create_limit, disabled_oauth_sign_in_sources: [], import_sources: [], repository_storages: [], diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index e51a5c7b84d..09dc4d118a1 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -14,9 +14,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action :expire_etag_cache, only: [:index], unless: -> { request.format.json? } before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do push_frontend_feature_flag(:prometheus_computed_alerts) - end - before_action do - push_frontend_feature_flag(:auto_stop_environments, default_enabled: true) + push_frontend_feature_flag(:metrics_dashboard_annotations) end after_action :expire_etag_cache, only: [:cancel_auto_stop] diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index f552c471eb2..96650e2cae9 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -42,6 +42,9 @@ class Projects::IssuesController < Projects::ApplicationController before_action :authorize_import_issues!, only: [:import_csv] before_action :authorize_download_code!, only: [:related_branches] + # Limit the amount of issues created per minute + before_action :create_rate_limit, only: [:create] + before_action do push_frontend_feature_flag(:vue_issuable_sidebar, project.group) push_frontend_feature_flag(:save_issuable_health_status, project.group, default_enabled: true) @@ -296,6 +299,22 @@ class Projects::IssuesController < Projects::ApplicationController # 3. https://gitlab.com/gitlab-org/gitlab-foss/issues/42426 Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42422') end + + private + + def create_rate_limit + key = :issues_create + + if rate_limiter.throttled?(key, scope: [@project, @current_user]) + rate_limiter.log_request(request, "#{key}_request_limit".to_sym, current_user) + + render plain: _('This endpoint has been requested too many times. Try again later.'), status: :too_many_requests + end + end + + def rate_limiter + ::Gitlab::ApplicationRateLimiter + end end Projects::IssuesController.prepend_if_ee('EE::Projects::IssuesController') diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 26de200a1c1..038b6146bab 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -24,6 +24,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:single_mr_diff_view, @project, default_enabled: true) push_frontend_feature_flag(:suggest_pipeline) if experiment_enabled?(:suggest_pipeline) push_frontend_feature_flag(:code_navigation, @project) + push_frontend_feature_flag(:widget_visibility_polling, @project, default_enabled: true) end before_action do diff --git a/app/mailers/emails/pages_domains.rb b/app/mailers/emails/pages_domains.rb index 1caca6b3e44..6c3dcf8746b 100644 --- a/app/mailers/emails/pages_domains.rb +++ b/app/mailers/emails/pages_domains.rb @@ -41,5 +41,16 @@ module Emails subject: subject("ACTION REQUIRED: Verification failed for GitLab Pages domain '#{domain.domain}'") ) end + + def pages_domain_auto_ssl_failed_email(domain, recipient) + @domain = domain + @project = domain.project + + subject_text = _("ACTION REQUIRED: Something went wrong while obtaining the Let's Encrypt certificate for GitLab Pages domain '%{domain}'") % { domain: domain.domain } + mail( + to: recipient.notification_email_for(@project.group), + subject: subject(subject_text) + ) + end end end diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 920ad3286d1..c96f086684f 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -79,6 +79,7 @@ module ApplicationSettingImplementation housekeeping_gc_period: 200, housekeeping_incremental_repack_period: 10, import_sources: Settings.gitlab['import_sources'], + issues_create_limit: 300, local_markdown_version: 0, max_artifacts_size: Settings.artifacts['max_size'], max_attachment_size: Settings.gitlab['max_attachment_size'], diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index ef0701b3874..c4ac10814a9 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -73,12 +73,14 @@ module Ci validates :file_format, presence: true, unless: :trace?, on: :create validate :valid_file_format?, unless: :trace?, on: :create - before_save :set_size, if: :file_changed? - update_project_statistics project_statistics_name: :build_artifacts_size + before_save :set_size, if: :file_changed? + before_save :set_file_store, if: ->(job_artifact) { job_artifact.file_store.nil? } after_save :update_file_store, if: :saved_change_to_file? + update_project_statistics project_statistics_name: :build_artifacts_size + scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) } scope :with_files_stored_remotely, -> { where(file_store: ::JobArtifactUploader::Store::REMOTE) } scope :for_sha, ->(sha, project_id) { joins(job: :pipeline).where(ci_pipelines: { sha: sha, project_id: project_id }) } @@ -226,6 +228,15 @@ module Ci self.size = file.size end + def set_file_store + self.file_store = + if JobArtifactUploader.object_store_enabled? && JobArtifactUploader.direct_upload_enabled? + JobArtifactUploader::Store::REMOTE + else + file.object_store + end + end + def project_destroyed? # Use job.project to avoid extra DB query for project job.project.pending_delete? diff --git a/app/models/diff_note_position.rb b/app/models/diff_note_position.rb new file mode 100644 index 00000000000..78e4fbc49eb --- /dev/null +++ b/app/models/diff_note_position.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class DiffNotePosition < ApplicationRecord + belongs_to :note + + enum diff_content_type: { + text: 0, + image: 1 + } + + enum diff_type: { + head: 0 + } + + def position + Gitlab::Diff::Position.new( + old_path: old_path, + new_path: new_path, + old_line: old_line, + new_line: new_line, + position_type: diff_content_type, + diff_refs: Gitlab::Diff::DiffRefs.new( + base_sha: base_sha, + start_sha: start_sha, + head_sha: head_sha + ) + ) + end + + def position=(position) + position_attrs = position.to_h + position_attrs[:diff_content_type] = position_attrs.delete(:position_type) + + assign_attributes(position_attrs) + end +end diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb index 6a86aebae39..c5233deaa96 100644 --- a/app/models/lfs_object.rb +++ b/app/models/lfs_object.rb @@ -17,6 +17,8 @@ class LfsObject < ApplicationRecord mount_uploader :file, LfsObjectUploader + before_save :set_file_store, if: ->(lfs_object) { lfs_object.file_store.nil? } + after_save :update_file_store, if: :saved_change_to_file? def self.not_linked_to_project(project) @@ -55,6 +57,17 @@ class LfsObject < ApplicationRecord def self.calculate_oid(path) self.hexdigest(path) end + + private + + def set_file_store + self.file_store = + if LfsObjectUploader.object_store_enabled? && LfsObjectUploader.direct_upload_enabled? + LfsObjectUploader::Store::REMOTE + else + file.object_store + end + end end LfsObject.prepend_if_ee('EE::LfsObject') diff --git a/app/services/clusters/create_service.rb b/app/services/clusters/create_service.rb index 5c26c611e00..7b5bf6b32c2 100644 --- a/app/services/clusters/create_service.rb +++ b/app/services/clusters/create_service.rb @@ -23,6 +23,8 @@ module Clusters cluster.errors.add(:base, _('Instance does not support multiple Kubernetes clusters')) end + validate_management_project_permissions(cluster) + return cluster if cluster.errors.present? cluster.tap do |cluster| @@ -57,6 +59,11 @@ module Clusters def can_create_cluster? clusterable.clusters.empty? end + + def validate_management_project_permissions(cluster) + Clusters::Management::ValidateManagementProjectPermissionsService.new(current_user) + .execute(cluster, params[:management_project_id]) + end end end diff --git a/app/services/clusters/management/validate_management_project_permissions_service.rb b/app/services/clusters/management/validate_management_project_permissions_service.rb new file mode 100644 index 00000000000..e89a0afe6d2 --- /dev/null +++ b/app/services/clusters/management/validate_management_project_permissions_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Clusters + module Management + class ValidateManagementProjectPermissionsService + attr_reader :current_user + + def initialize(user = nil) + @current_user = user + end + + def execute(cluster, management_project_id) + if management_project_id.present? + management_project = management_project_scope(cluster).find_by_id(management_project_id) + + unless management_project && can_admin_pipeline_for_project?(management_project) + cluster.errors.add(:management_project_id, _('Project does not exist or you don\'t have permission to perform this action')) + + return false + end + end + + true + end + + private + + def can_admin_pipeline_for_project?(project) + Ability.allowed?(current_user, :admin_pipeline, project) + end + + def management_project_scope(cluster) + return ::Project.all if cluster.instance_type? + + group = + if cluster.group_type? + cluster.first_group + elsif cluster.project_type? + cluster.first_project&.namespace + end + + # Prevent users from selecting nested projects until + # https://gitlab.com/gitlab-org/gitlab/issues/34650 is resolved + include_subgroups = cluster.group_type? + + ::GroupProjectsFinder.new( + group: group, + current_user: current_user, + options: { only_owned: true, include_subgroups: include_subgroups } + ).execute + end + end + end +end diff --git a/app/services/clusters/update_service.rb b/app/services/clusters/update_service.rb index 8cb77040b14..2315df612a1 100644 --- a/app/services/clusters/update_service.rb +++ b/app/services/clusters/update_service.rb @@ -18,46 +18,9 @@ module Clusters private - def can_admin_pipeline_for_project?(project) - Ability.allowed?(current_user, :admin_pipeline, project) - end - def validate_params(cluster) - if params[:management_project_id].present? - management_project = management_project_scope(cluster).find_by_id(params[:management_project_id]) - - unless management_project - cluster.errors.add(:management_project_id, _('Project does not exist or you don\'t have permission to perform this action')) - - return false - end - - unless can_admin_pipeline_for_project?(management_project) - # Use same message as not found to prevent enumeration - cluster.errors.add(:management_project_id, _('Project does not exist or you don\'t have permission to perform this action')) - - return false - end - end - - true - end - - def management_project_scope(cluster) - return ::Project.all if cluster.instance_type? - - group = - if cluster.group_type? - cluster.first_group - elsif cluster.project_type? - cluster.first_project&.namespace - end - - # Prevent users from selecting nested projects until - # https://gitlab.com/gitlab-org/gitlab/issues/34650 is resolved - include_subgroups = cluster.group_type? - - ::GroupProjectsFinder.new(group: group, current_user: current_user, options: { only_owned: true, include_subgroups: include_subgroups }).execute + ::Clusters::Management::ValidateManagementProjectPermissionsService.new(current_user) + .execute(cluster, params[:management_project_id]) end end end diff --git a/app/services/environments/auto_stop_service.rb b/app/services/environments/auto_stop_service.rb index ee7f25a4d76..bde598abf66 100644 --- a/app/services/environments/auto_stop_service.rb +++ b/app/services/environments/auto_stop_service.rb @@ -30,7 +30,7 @@ module Environments def stop_in_batch environments = Environment.auto_stoppable(BATCH_SIZE) - return false unless environments.exists? && Feature.enabled?(:auto_stop_environments, default_enabled: true) + return false unless environments.exists? Ci::StopEnvironmentsService.execute_in_batch(environments) end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 62827f20929..91e19d190bd 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -489,6 +489,12 @@ class NotificationService end end + def pages_domain_auto_ssl_failed(domain) + project_maintainers_recipients(domain, action: 'disabled').each do |recipient| + mailer.pages_domain_auto_ssl_failed_email(domain, recipient.user).deliver_later + end + end + def issue_due(issue) recipients = NotificationRecipients::BuildService.build_recipients( issue, diff --git a/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb index 93445dd4ddd..1c03641469e 100644 --- a/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb +++ b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb @@ -57,6 +57,8 @@ module PagesDomains pages_domain.save!(validate: false) acme_order.destroy! + + NotificationService.new.pages_domain_auto_ssl_failed(pages_domain) end def log_error(api_order) diff --git a/app/uploaders/records_uploads.rb b/app/uploaders/records_uploads.rb index 967fcdc704e..427314a87bb 100644 --- a/app/uploaders/records_uploads.rb +++ b/app/uploaders/records_uploads.rb @@ -56,10 +56,31 @@ module RecordsUploads size: file.size, path: upload_path, model: model, - mount_point: mounted_as + mount_point: mounted_as, + store: initial_store ) end + def initial_store + if immediately_remote_stored? + ::ObjectStorage::Store::REMOTE + else + ::ObjectStorage::Store::LOCAL + end + end + + def immediately_remote_stored? + object_storage_available? && direct_upload_enabled? + end + + def object_storage_available? + self.class.ancestors.include?(ObjectStorage::Concern) + end + + def direct_upload_enabled? + self.class.object_store_enabled? && self.class.direct_upload_enabled? + end + # Before removing an attachment, destroy any Upload records at the same path # # Called `before :remove` diff --git a/app/views/admin/application_settings/_issue_limits.html.haml b/app/views/admin/application_settings/_issue_limits.html.haml new file mode 100644 index 00000000000..5906358fbb1 --- /dev/null +++ b/app/views/admin/application_settings/_issue_limits.html.haml @@ -0,0 +1,9 @@ += form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-issue-limits-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :issues_create_limit, 'Max requests per second per user', class: 'label-bold' + = f.number_field :issues_create_limit, class: 'form-control' + + = f.submit 'Save changes', class: "btn btn-success", data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml index 8d88dedf832..db4611964b4 100644 --- a/app/views/admin/application_settings/network.html.haml +++ b/app/views/admin/application_settings/network.html.haml @@ -46,4 +46,15 @@ .settings-content = render 'protected_paths' +%section.settings.as-issue-limits.no-animate#js-issue-limits-settings{ class: ('expanded' if expanded_by_default?) } + .settings-header + %h4 + = _('Issues Rate Limits') + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded_by_default? ? _('Collapse') : _('Expand') + %p + = _('Configure limit for issues created per minute by web and API requests.') + .settings-content + = render 'issue_limits' + = render_if_exists 'admin/application_settings/ee_network_settings' diff --git a/app/views/admin/deploy_keys/index.html.haml b/app/views/admin/deploy_keys/index.html.haml index 9fffa97f969..4e9cfc13af0 100644 --- a/app/views/admin/deploy_keys/index.html.haml +++ b/app/views/admin/deploy_keys/index.html.haml @@ -1,7 +1,7 @@ - page_title _('Deploy Keys') %h3.page-title.deploy-keys-title - = _('Public deploy keys (%{deploy_keys_count})') % { deploy_keys_count: @deploy_keys.count } + = _('Public deploy keys (%{deploy_keys_count})') % { deploy_keys_count: @deploy_keys.load.size } .float-right = link_to _('New deploy key'), new_admin_deploy_key_path, class: 'btn btn-success btn-sm btn-inverted' diff --git a/app/views/notify/pages_domain_auto_ssl_failed_email.html.haml b/app/views/notify/pages_domain_auto_ssl_failed_email.html.haml new file mode 100644 index 00000000000..1bc2cc15616 --- /dev/null +++ b/app/views/notify/pages_domain_auto_ssl_failed_email.html.haml @@ -0,0 +1,11 @@ +%p + = _("Something went wrong while obtaining the Let's Encrypt certificate.") +%p + #{_('Project')}: #{link_to @project.human_name, project_url(@project)} +%p + #{_('Domain')}: #{link_to @domain.domain, project_pages_domain_url(@project, @domain)} +%p + - docs_url = help_page_url('user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md', anchor: 'troubleshooting') + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_url } + - link_end = '</a>'.html_safe + = _("Please follow the %{link_start}Let\'s Encrypt troubleshooting instructions%{link_end} to re-obtain your Let's Encrypt certificate.").html_safe % { link_start: link_start, link_end: link_end } diff --git a/app/views/notify/pages_domain_auto_ssl_failed_email.text.haml b/app/views/notify/pages_domain_auto_ssl_failed_email.text.haml new file mode 100644 index 00000000000..6f20d11c966 --- /dev/null +++ b/app/views/notify/pages_domain_auto_ssl_failed_email.text.haml @@ -0,0 +1,7 @@ += _("Something went wrong while obtaining the Let's Encrypt certificate.").html_safe + +#{_('Project')}: #{project_url(@project)} +#{_('Domain')}: #{project_pages_domain_url(@project, @domain)} + +- docs_url = help_page_url('user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md', anchor: 'troubleshooting') += _("Please follow the Let\'s Encrypt troubleshooting instructions to re-obtain your Let's Encrypt certificate: %{docs_url}.").html_safe % { docs_url: docs_url } diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml index 6ea4eeb66c5..e28c74dd650 100644 --- a/app/views/profiles/emails/index.html.haml +++ b/app/views/profiles/emails/index.html.haml @@ -18,7 +18,7 @@ = f.submit _('Add email address'), class: 'btn btn-success', data: { qa_selector: 'add_email_address_button' } %hr %h4.prepend-top-0 - = _('Linked emails (%{email_count})') % { email_count: @emails.count + 1 } + = _('Linked emails (%{email_count})') % { email_count: @emails.load.size + 1 } .account-well.append-bottom-default %ul %li diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml index 6da4956a036..69b030ed76a 100644 --- a/app/views/projects/issues/_related_branches.html.haml +++ b/app/views/projects/issues/_related_branches.html.haml @@ -1,6 +1,6 @@ - if @related_branches.any? %h2.related-branches-title - = pluralize(@related_branches.count, 'Related Branch') + = pluralize(@related_branches.size, 'Related Branch') %ul.unstyled-list.related-merge-requests - @related_branches.each do |branch| %li diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml index 0d40f375926..a9e2cbac890 100644 --- a/app/views/projects/pages/_list.html.haml +++ b/app/views/projects/pages/_list.html.haml @@ -1,9 +1,9 @@ - verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled? -- if can?(current_user, :update_pages, @project) && @domains.any? +- if can?(current_user, :update_pages, @project) && @domains.load.any? .card .card-header - Domains (#{@domains.count}) + Domains (#{@domains.size}) %ul.list-group.list-group-flush.pages-domain-list{ class: ("has-verification-status" if verification_enabled) } - @domains.each do |domain| - domain = Gitlab::View::Presenter::Factory.new(domain, current_user: current_user).fabricate! diff --git a/app/workers/environments/auto_stop_cron_worker.rb b/app/workers/environments/auto_stop_cron_worker.rb index de5e10a0976..ada52d3402d 100644 --- a/app/workers/environments/auto_stop_cron_worker.rb +++ b/app/workers/environments/auto_stop_cron_worker.rb @@ -8,8 +8,6 @@ module Environments feature_category :continuous_delivery def perform - return unless Feature.enabled?(:auto_stop_environments, default_enabled: true) - AutoStopService.new.execute end end diff --git a/changelogs/unreleased/211998-add-cluster-mangement-id-on-create.yml b/changelogs/unreleased/211998-add-cluster-mangement-id-on-create.yml new file mode 100644 index 00000000000..cca81a37179 --- /dev/null +++ b/changelogs/unreleased/211998-add-cluster-mangement-id-on-create.yml @@ -0,0 +1,6 @@ +--- +title: Add management_project_id to group and project cluster creation, clarifies + docs. +merge_request: 28289 +author: +type: fixed diff --git a/changelogs/unreleased/212561-fix-empty-edit-area.yml b/changelogs/unreleased/212561-fix-empty-edit-area.yml new file mode 100644 index 00000000000..0e45110265e --- /dev/null +++ b/changelogs/unreleased/212561-fix-empty-edit-area.yml @@ -0,0 +1,5 @@ +--- +title: 'fix: Publish toolbar dissappears when submitting empty content' +merge_request: 29410 +author: +type: fixed diff --git a/changelogs/unreleased/213299-env-autostop-bug.yml b/changelogs/unreleased/213299-env-autostop-bug.yml new file mode 100644 index 00000000000..f4d3198f8bd --- /dev/null +++ b/changelogs/unreleased/213299-env-autostop-bug.yml @@ -0,0 +1,5 @@ +--- +title: Add autostop check to folder table +merge_request: 28937 +author: +type: fixed diff --git a/changelogs/unreleased/213382-use-not-valid-to-immediately-enforce-a-not-null-constraint.yml b/changelogs/unreleased/213382-use-not-valid-to-immediately-enforce-a-not-null-constraint.yml new file mode 100644 index 00000000000..066c8e2c45c --- /dev/null +++ b/changelogs/unreleased/213382-use-not-valid-to-immediately-enforce-a-not-null-constraint.yml @@ -0,0 +1,6 @@ +--- +title: Use NOT VALID to enforce a NOT NULL constraint on file_store to ci_job_artifacts, + lfs_objects and uploads tables +merge_request: 28946 +author: +type: fixed diff --git a/changelogs/unreleased/213799-optimize-usage_activity_by_stage-projects_with_repositories_enable.yml b/changelogs/unreleased/213799-optimize-usage_activity_by_stage-projects_with_repositories_enable.yml new file mode 100644 index 00000000000..af6a79a59b5 --- /dev/null +++ b/changelogs/unreleased/213799-optimize-usage_activity_by_stage-projects_with_repositories_enable.yml @@ -0,0 +1,5 @@ +--- +title: Optimize projects with repositories enabled usage data +merge_request: 29117 +author: +type: performance diff --git a/changelogs/unreleased/55241-rate-limit-issue-creation.yml b/changelogs/unreleased/55241-rate-limit-issue-creation.yml new file mode 100644 index 00000000000..76b3269ecb0 --- /dev/null +++ b/changelogs/unreleased/55241-rate-limit-issue-creation.yml @@ -0,0 +1,5 @@ +--- +title: Introduce rate limit for creating issues via web UI +merge_request: 28129 +author: +type: performance diff --git a/changelogs/unreleased/bvl-remove-sidekiq-deduplication-feature-flag.yml b/changelogs/unreleased/bvl-remove-sidekiq-deduplication-feature-flag.yml new file mode 100644 index 00000000000..cc8da28f6ca --- /dev/null +++ b/changelogs/unreleased/bvl-remove-sidekiq-deduplication-feature-flag.yml @@ -0,0 +1,5 @@ +--- +title: Avoid scheduling duplicate sidekiq jobs +merge_request: 29116 +author: +type: performance diff --git a/changelogs/unreleased/ph-210377-increaseMrPollTimes.yml b/changelogs/unreleased/ph-210377-increaseMrPollTimes.yml new file mode 100644 index 00000000000..fb953f03e60 --- /dev/null +++ b/changelogs/unreleased/ph-210377-increaseMrPollTimes.yml @@ -0,0 +1,5 @@ +--- +title: Increase the timing of polling for the merge request widget +merge_request: +author: +type: changed diff --git a/changelogs/unreleased/vs-migrate-deprecated-size-in-loading-icon.yml b/changelogs/unreleased/vs-migrate-deprecated-size-in-loading-icon.yml new file mode 100644 index 00000000000..f78fa371c22 --- /dev/null +++ b/changelogs/unreleased/vs-migrate-deprecated-size-in-loading-icon.yml @@ -0,0 +1,5 @@ +--- +title: Replace deprecated GlLoadingIcon sizes +merge_request: 29417 +author: +type: fixed diff --git a/db/migrate/20200325111432_add_issues_create_limit_to_application_settings.rb b/db/migrate/20200325111432_add_issues_create_limit_to_application_settings.rb new file mode 100644 index 00000000000..60da96ccf33 --- /dev/null +++ b/db/migrate/20200325111432_add_issues_create_limit_to_application_settings.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddIssuesCreateLimitToApplicationSettings < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def change + add_column :application_settings, :issues_create_limit, :integer, default: 300, null: false + end +end diff --git a/db/migrate/20200326122700_create_diff_note_positions.rb b/db/migrate/20200326122700_create_diff_note_positions.rb new file mode 100644 index 00000000000..87159e666b5 --- /dev/null +++ b/db/migrate/20200326122700_create_diff_note_positions.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class CreateDiffNotePositions < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + with_lock_retries do + create_table :diff_note_positions do |t| + t.references :note, foreign_key: { on_delete: :cascade }, null: false, index: false + t.integer :old_line + t.integer :new_line + t.integer :diff_content_type, limit: 2, null: false + t.integer :diff_type, limit: 2, null: false + t.string :line_code, limit: 255, null: false + t.binary :base_sha, null: false + t.binary :start_sha, null: false + t.binary :head_sha, null: false + t.text :old_path, null: false + t.text :new_path, null: false + + t.index [:note_id, :diff_type], unique: true + end + end + end + + def down + drop_table :diff_note_positions + end +end diff --git a/db/migrate/20200406165950_add_not_null_constraint_on_file_store_to_lfs_objects.rb b/db/migrate/20200406165950_add_not_null_constraint_on_file_store_to_lfs_objects.rb new file mode 100644 index 00000000000..78b5832fea4 --- /dev/null +++ b/db/migrate/20200406165950_add_not_null_constraint_on_file_store_to_lfs_objects.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class AddNotNullConstraintOnFileStoreToLfsObjects < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + CONSTRAINT_NAME = 'lfs_objects_file_store_not_null' + DOWNTIME = false + + def up + with_lock_retries do + execute <<~SQL + ALTER TABLE lfs_objects ADD CONSTRAINT #{CONSTRAINT_NAME} CHECK (file_store IS NOT NULL) NOT VALID; + SQL + end + end + + def down + with_lock_retries do + execute <<~SQL + ALTER TABLE lfs_objects DROP CONSTRAINT IF EXISTS #{CONSTRAINT_NAME}; + SQL + end + end +end diff --git a/db/migrate/20200406171857_add_not_null_constraint_on_file_store_to_ci_job_artifacts.rb b/db/migrate/20200406171857_add_not_null_constraint_on_file_store_to_ci_job_artifacts.rb new file mode 100644 index 00000000000..1d44e5c17b3 --- /dev/null +++ b/db/migrate/20200406171857_add_not_null_constraint_on_file_store_to_ci_job_artifacts.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class AddNotNullConstraintOnFileStoreToCiJobArtifacts < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + CONSTRAINT_NAME = 'ci_job_artifacts_file_store_not_null' + DOWNTIME = false + + def up + with_lock_retries do + execute <<~SQL + ALTER TABLE ci_job_artifacts ADD CONSTRAINT #{CONSTRAINT_NAME} CHECK (file_store IS NOT NULL) NOT VALID; + SQL + end + end + + def down + with_lock_retries do + execute <<~SQL + ALTER TABLE ci_job_artifacts DROP CONSTRAINT IF EXISTS #{CONSTRAINT_NAME}; + SQL + end + end +end diff --git a/db/migrate/20200406172135_add_not_null_constraint_on_file_store_to_uploads.rb b/db/migrate/20200406172135_add_not_null_constraint_on_file_store_to_uploads.rb new file mode 100644 index 00000000000..aa498ba9c89 --- /dev/null +++ b/db/migrate/20200406172135_add_not_null_constraint_on_file_store_to_uploads.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class AddNotNullConstraintOnFileStoreToUploads < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + CONSTRAINT_NAME = 'uploads_store_not_null' + DOWNTIME = false + + def up + with_lock_retries do + execute <<~SQL + ALTER TABLE uploads ADD CONSTRAINT #{CONSTRAINT_NAME} CHECK (store IS NOT NULL) NOT VALID; + SQL + end + end + + def down + with_lock_retries do + execute <<~SQL + ALTER TABLE uploads DROP CONSTRAINT IF EXISTS #{CONSTRAINT_NAME}; + SQL + end + end +end diff --git a/db/migrate/20200408153842_add_index_on_creator_id_and_id_on_projects.rb b/db/migrate/20200408153842_add_index_on_creator_id_and_id_on_projects.rb new file mode 100644 index 00000000000..2cc91efcc36 --- /dev/null +++ b/db/migrate/20200408153842_add_index_on_creator_id_and_id_on_projects.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddIndexOnCreatorIdAndIdOnProjects < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :projects, [:creator_id, :id] + end + + def down + remove_concurrent_index :projects, [:creator_id, :id] + end +end diff --git a/db/structure.sql b/db/structure.sql index 895afed92e6..cfa0f3c405a 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -397,6 +397,7 @@ CREATE TABLE public.application_settings ( email_restrictions text, npm_package_requests_forwarding boolean DEFAULT true NOT NULL, namespace_storage_size_limit bigint DEFAULT 0 NOT NULL, + issues_create_limit integer DEFAULT 300 NOT NULL, seat_link_enabled boolean DEFAULT true NOT NULL, container_expiration_policies_enable_historic_entries boolean DEFAULT false NOT NULL ); @@ -2138,6 +2139,30 @@ CREATE SEQUENCE public.design_user_mentions_id_seq ALTER SEQUENCE public.design_user_mentions_id_seq OWNED BY public.design_user_mentions.id; +CREATE TABLE public.diff_note_positions ( + id bigint NOT NULL, + note_id bigint NOT NULL, + old_line integer, + new_line integer, + diff_content_type smallint NOT NULL, + diff_type smallint NOT NULL, + line_code character varying(255) NOT NULL, + base_sha bytea NOT NULL, + start_sha bytea NOT NULL, + head_sha bytea NOT NULL, + old_path text NOT NULL, + new_path text NOT NULL +); + +CREATE SEQUENCE public.diff_note_positions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.diff_note_positions_id_seq OWNED BY public.diff_note_positions.id; + CREATE TABLE public.draft_notes ( id bigint NOT NULL, merge_request_id integer NOT NULL, @@ -7124,6 +7149,8 @@ ALTER TABLE ONLY public.design_management_versions ALTER COLUMN id SET DEFAULT n ALTER TABLE ONLY public.design_user_mentions ALTER COLUMN id SET DEFAULT nextval('public.design_user_mentions_id_seq'::regclass); +ALTER TABLE ONLY public.diff_note_positions ALTER COLUMN id SET DEFAULT nextval('public.diff_note_positions_id_seq'::regclass); + ALTER TABLE ONLY public.draft_notes ALTER COLUMN id SET DEFAULT nextval('public.draft_notes_id_seq'::regclass); ALTER TABLE ONLY public.emails ALTER COLUMN id SET DEFAULT nextval('public.emails_id_seq'::regclass); @@ -7670,6 +7697,9 @@ ALTER TABLE ONLY public.ci_daily_report_results ALTER TABLE ONLY public.ci_group_variables ADD CONSTRAINT ci_group_variables_pkey PRIMARY KEY (id); +ALTER TABLE public.ci_job_artifacts + ADD CONSTRAINT ci_job_artifacts_file_store_not_null CHECK ((file_store IS NOT NULL)) NOT VALID; + ALTER TABLE ONLY public.ci_job_artifacts ADD CONSTRAINT ci_job_artifacts_pkey PRIMARY KEY (id); @@ -7829,6 +7859,9 @@ ALTER TABLE ONLY public.design_management_versions ALTER TABLE ONLY public.design_user_mentions ADD CONSTRAINT design_user_mentions_pkey PRIMARY KEY (id); +ALTER TABLE ONLY public.diff_note_positions + ADD CONSTRAINT diff_note_positions_pkey PRIMARY KEY (id); + ALTER TABLE ONLY public.draft_notes ADD CONSTRAINT draft_notes_pkey PRIMARY KEY (id); @@ -8024,6 +8057,9 @@ ALTER TABLE ONLY public.ldap_group_links ALTER TABLE ONLY public.lfs_file_locks ADD CONSTRAINT lfs_file_locks_pkey PRIMARY KEY (id); +ALTER TABLE public.lfs_objects + ADD CONSTRAINT lfs_objects_file_store_not_null CHECK ((file_store IS NOT NULL)) NOT VALID; + ALTER TABLE ONLY public.lfs_objects ADD CONSTRAINT lfs_objects_pkey PRIMARY KEY (id); @@ -8417,6 +8453,9 @@ ALTER TABLE ONLY public.u2f_registrations ALTER TABLE ONLY public.uploads ADD CONSTRAINT uploads_pkey PRIMARY KEY (id); +ALTER TABLE public.uploads + ADD CONSTRAINT uploads_store_not_null CHECK ((store IS NOT NULL)) NOT VALID; + ALTER TABLE ONLY public.user_agent_details ADD CONSTRAINT user_agent_details_pkey PRIMARY KEY (id); @@ -9086,6 +9125,8 @@ CREATE UNIQUE INDEX index_design_management_versions_on_sha_and_issue_id ON publ CREATE UNIQUE INDEX index_design_user_mentions_on_note_id ON public.design_user_mentions USING btree (note_id); +CREATE UNIQUE INDEX index_diff_note_positions_on_note_id_and_diff_type ON public.diff_note_positions USING btree (note_id, diff_type); + CREATE INDEX index_draft_notes_on_author_id ON public.draft_notes USING btree (author_id); CREATE INDEX index_draft_notes_on_discussion_id ON public.draft_notes USING btree (discussion_id); @@ -9886,6 +9927,8 @@ CREATE INDEX index_projects_on_creator_id_and_created_at ON public.projects USIN CREATE INDEX index_projects_on_creator_id_and_created_at_and_id ON public.projects USING btree (creator_id, created_at, id); +CREATE INDEX index_projects_on_creator_id_and_id ON public.projects USING btree (creator_id, id); + CREATE INDEX index_projects_on_description_trigram ON public.projects USING gin (description public.gin_trgm_ops); CREATE INDEX index_projects_on_id_and_archived_and_pending_delete ON public.projects USING btree (id) WHERE ((archived = false) AND (pending_delete = false)); @@ -11068,6 +11111,9 @@ ALTER TABLE ONLY public.project_statistics ALTER TABLE ONLY public.user_details ADD CONSTRAINT fk_rails_12e0b3043d FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; +ALTER TABLE ONLY public.diff_note_positions + ADD CONSTRAINT fk_rails_13c7212859 FOREIGN KEY (note_id) REFERENCES public.notes(id) ON DELETE CASCADE; + ALTER TABLE ONLY public.users_security_dashboard_projects ADD CONSTRAINT fk_rails_150cd5682c FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; @@ -13064,10 +13110,12 @@ COPY "schema_migrations" (version) FROM STDIN; 20200323134519 20200324093258 20200324115359 +20200325111432 20200325152327 20200325160952 20200325183636 20200326114443 +20200326122700 20200326124443 20200326134443 20200326135443 @@ -13090,10 +13138,14 @@ COPY "schema_migrations" (version) FROM STDIN; 20200403185127 20200403185422 20200406135648 +20200406165950 +20200406171857 +20200406172135 20200406192059 20200407094005 20200407094923 20200408110856 +20200408153842 20200408175424 \. diff --git a/doc/api/group_clusters.md b/doc/api/group_clusters.md index e9b4b2b92ab..01c6d59f60d 100644 --- a/doc/api/group_clusters.md +++ b/doc/api/group_clusters.md @@ -224,6 +224,7 @@ Parameters: | `cluster_id` | integer | yes | The ID of the cluster | | `name` | string | no | The name of the cluster | | `domain` | string | no | The [base domain](../user/group/clusters/index.md#base-domain) of the cluster | +| `management_project_id` | integer | no | The ID of the [management project](../user/clusters/management_project.md) for the cluster | | `platform_kubernetes_attributes[api_url]` | string | no | The URL to access the Kubernetes API | | `platform_kubernetes_attributes[token]` | string | no | The token to authenticate against Kubernetes | | `platform_kubernetes_attributes[ca_cert]` | string | no | TLS certificate. Required if API is using a self-signed TLS certificate. | diff --git a/doc/api/project_clusters.md b/doc/api/project_clusters.md index 2ed57eceb85..79800af2f59 100644 --- a/doc/api/project_clusters.md +++ b/doc/api/project_clusters.md @@ -179,6 +179,7 @@ Parameters: | `id` | integer | yes | The ID of the project owned by the authenticated user | | `name` | string | yes | The name of the cluster | | `domain` | string | no | The [base domain](../user/project/clusters/index.md#base-domain) of the cluster | +| `management_project_id` | integer | no | The ID of the [management project](../user/clusters/management_project.md) for the cluster | | `enabled` | boolean | no | Determines if cluster is active or not, defaults to true | | `managed` | boolean | no | Determines if GitLab will manage namespaces and service accounts for this cluster, defaults to true | | `platform_kubernetes_attributes[api_url]` | string | yes | The URL to access the Kubernetes API | diff --git a/doc/user/project/repository/forking_workflow.md b/doc/user/project/repository/forking_workflow.md index 927c1db804a..c26f2bd6b1d 100644 --- a/doc/user/project/repository/forking_workflow.md +++ b/doc/user/project/repository/forking_workflow.md @@ -54,6 +54,9 @@ When you are ready to send your code back to the upstream project, [create a merge request](../merge_requests/creating_merge_requests.md). For **Source branch**, choose your forked project's branch. For **Target branch**, choose the original project's branch. +NOTE: **Note:** +When creating a merge request, if the forked project's visibility is more restrictive than the parent project (for example the fork is private, parent is public), the target branch will default to the forked project's default branch. This prevents potentially exposing private code of the forked project. + ![Selecting branches](img/forking_workflow_branch_select.png) Then you can add labels, a milestone, and assign the merge request to someone who can review diff --git a/lib/api/group_clusters.rb b/lib/api/group_clusters.rb index 0108f6feae3..2c12c6387fb 100644 --- a/lib/api/group_clusters.rb +++ b/lib/api/group_clusters.rb @@ -53,6 +53,7 @@ module API requires :name, type: String, desc: 'Cluster name' optional :enabled, type: Boolean, default: true, desc: 'Determines if cluster is active or not, defaults to true' optional :domain, type: String, desc: 'Cluster base domain' + optional :management_project_id, type: Integer, desc: 'The ID of the management project' optional :managed, type: Boolean, default: true, desc: 'Determines if GitLab will manage namespaces and service accounts for this cluster, defaults to true' requires :platform_kubernetes_attributes, type: Hash, desc: %q(Platform Kubernetes data) do requires :api_url, type: String, allow_blank: false, desc: 'URL to access the Kubernetes API' diff --git a/lib/api/project_clusters.rb b/lib/api/project_clusters.rb index b482980b88a..299301aabc4 100644 --- a/lib/api/project_clusters.rb +++ b/lib/api/project_clusters.rb @@ -56,6 +56,7 @@ module API requires :name, type: String, desc: 'Cluster name' optional :enabled, type: Boolean, default: true, desc: 'Determines if cluster is active or not, defaults to true' optional :domain, type: String, desc: 'Cluster base domain' + optional :management_project_id, type: Integer, desc: 'The ID of the management project' optional :managed, type: Boolean, default: true, desc: 'Determines if GitLab will manage namespaces and service accounts for this cluster, defaults to true' requires :platform_kubernetes_attributes, type: Hash, desc: %q(Platform Kubernetes data) do requires :api_url, type: String, allow_blank: false, desc: 'URL to access the Kubernetes API' diff --git a/lib/gitlab/application_rate_limiter.rb b/lib/gitlab/application_rate_limiter.rb index c1066d8fa62..2defbd26b98 100644 --- a/lib/gitlab/application_rate_limiter.rb +++ b/lib/gitlab/application_rate_limiter.rb @@ -19,8 +19,9 @@ module Gitlab # and only do that when it's needed. def rate_limits { - project_export: { threshold: 1, interval: 5.minutes }, - project_download_export: { threshold: 10, interval: 10.minutes }, + issues_create: { threshold: -> { Gitlab::CurrentSettings.current_application_settings.issues_create_limit }, interval: 1.minute }, + project_export: { threshold: 1, interval: 5.minutes }, + project_download_export: { threshold: 10, interval: 10.minutes }, project_repositories_archive: { threshold: 5, interval: 1.minute }, project_generate_new_export: { threshold: 1, interval: 5.minutes }, project_import: { threshold: 30, interval: 5.minutes }, diff --git a/lib/gitlab/sidekiq_middleware/duplicate_jobs.rb b/lib/gitlab/sidekiq_middleware/duplicate_jobs.rb deleted file mode 100644 index 7a77a56d642..00000000000 --- a/lib/gitlab/sidekiq_middleware/duplicate_jobs.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -require 'digest' - -module Gitlab - module SidekiqMiddleware - module DuplicateJobs - DROPPABLE_QUEUES = Set.new([ - Namespaces::RootStatisticsWorker.queue, - Namespaces::ScheduleAggregationWorker.queue - ]).freeze - - def self.drop_duplicates?(queue_name) - Feature.enabled?(:drop_duplicate_sidekiq_jobs) || - drop_duplicates_for_queue?(queue_name) - end - - private_class_method def self.drop_duplicates_for_queue?(queue_name) - DROPPABLE_QUEUES.include?(queue_name) && - Feature.enabled?(:drop_duplicate_sidekiq_jobs_for_queue) - end - end - end -end diff --git a/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job.rb b/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job.rb index a9007039334..79bbb99752e 100644 --- a/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job.rb +++ b/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job.rb @@ -67,7 +67,7 @@ module Gitlab end def droppable? - idempotent? && duplicate? && DuplicateJobs.drop_duplicates?(queue_name) + idempotent? && duplicate? end private diff --git a/locale/gitlab.pot b/locale/gitlab.pot index d45e3763a32..220c41fa318 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -894,6 +894,9 @@ msgstr "" msgid "A user with write access to the source branch selected this option" msgstr "" +msgid "ACTION REQUIRED: Something went wrong while obtaining the Let's Encrypt certificate for GitLab Pages domain '%{domain}'" +msgstr "" + msgid "API Help" msgstr "" @@ -5334,6 +5337,9 @@ msgstr "" msgid "Configure existing installation" msgstr "" +msgid "Configure limit for issues created per minute by web and API requests." +msgstr "" + msgid "Configure limits for web and API requests." msgstr "" @@ -11385,6 +11391,9 @@ msgstr "" msgid "Issues Analytics" msgstr "" +msgid "Issues Rate Limits" +msgstr "" + msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable." msgstr "" @@ -12941,9 +12950,15 @@ msgstr "" msgid "Metrics|There was an error creating the dashboard. %{error}" msgstr "" +msgid "Metrics|There was an error fetching annotations. Please try again." +msgstr "" + msgid "Metrics|There was an error fetching the environments data, please try again" msgstr "" +msgid "Metrics|There was an error getting annotations information." +msgstr "" + msgid "Metrics|There was an error getting deployment information." msgstr "" @@ -14900,6 +14915,12 @@ msgstr "" msgid "Please fill in a descriptive name for your group." msgstr "" +msgid "Please follow the %{link_start}Let's Encrypt troubleshooting instructions%{link_end} to re-obtain your Let's Encrypt certificate." +msgstr "" + +msgid "Please follow the Let's Encrypt troubleshooting instructions to re-obtain your Let's Encrypt certificate: %{docs_url}." +msgstr "" + msgid "Please migrate all existing projects to hashed storage to avoid security issues and ensure data integrity. %{migrate_link}" msgstr "" @@ -18939,6 +18960,9 @@ msgstr "" msgid "Something went wrong while moving issues." msgstr "" +msgid "Something went wrong while obtaining the Let's Encrypt certificate." +msgstr "" + msgid "Something went wrong while performing the action." msgstr "" diff --git a/package.json b/package.json index a0abec5643f..a48b2468ea8 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@gitlab/ui": "11.2.1", "@gitlab/visual-review-tools": "1.5.1", "@sentry/browser": "^5.10.2", - "@sourcegraph/code-host-integration": "0.0.34", + "@sourcegraph/code-host-integration": "0.0.36", "apollo-cache-inmemory": "^1.6.3", "apollo-client": "^2.6.4", "apollo-link": "^1.2.11", diff --git a/rubocop/cop/performance/ar_count_each.rb b/rubocop/cop/performance/ar_count_each.rb new file mode 100644 index 00000000000..2fe8e549872 --- /dev/null +++ b/rubocop/cop/performance/ar_count_each.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module RuboCop + module Cop + module Performance + class ARCountEach < RuboCop::Cop::Cop + def message(ivar) + "If #{ivar} is AR relation, avoid `#{ivar}.count ...; #{ivar}.each... `, this will trigger two queries. " \ + "Use `#{ivar}.load.size ...; #{ivar}.each... ` instead. If #{ivar} is an array, try to use #{ivar}.size." + end + + def_node_matcher :count_match, <<~PATTERN + (send (ivar $_) :count) + PATTERN + + def_node_matcher :each_match, <<~PATTERN + (send (ivar $_) :each) + PATTERN + + def file_name(node) + node.location.expression.source_buffer.name + end + + def in_haml_file?(node) + file_name(node).end_with?('.haml.rb') + end + + def on_send(node) + return unless in_haml_file?(node) + + ivar_count = count_match(node) + return unless ivar_count + + node.each_ancestor(:begin) do |begin_node| + begin_node.each_descendant do |n| + ivar_each = each_match(n) + + add_offense(node, location: :expression, message: message(ivar_count)) if ivar_each == ivar_count + end + end + end + end + end + end +end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 74ed4a0f991..fdc8fe5f082 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -1085,6 +1085,48 @@ describe Projects::IssuesController do expect { subject }.to change(SentryIssue, :count) end end + + context 'when the endpoint receives requests above the limit' do + before do + stub_application_setting(issues_create_limit: 5) + end + + it 'prevents from creating more issues', :request_store do + 5.times { post_new_issue } + + expect { post_new_issue } + .to change { Gitlab::GitalyClient.get_request_count }.by(1) # creates 1 projects and 0 issues + + post_new_issue + expect(response.body).to eq(_('This endpoint has been requested too many times. Try again later.')) + expect(response).to have_gitlab_http_status(:too_many_requests) + end + + it 'logs the event on auth.log' do + attributes = { + message: 'Application_Rate_Limiter_Request', + env: :issues_create_request_limit, + remote_ip: '0.0.0.0', + request_method: 'POST', + path: "/#{project.full_path}/-/issues", + user_id: user.id, + username: user.username + } + + expect(Gitlab::AuthLogger).to receive(:error).with(attributes).once + + project.add_developer(user) + sign_in(user) + + 6.times do + post :create, params: { + namespace_id: project.namespace.to_param, + project_id: project, + issue: { title: 'Title', description: 'Description' } + } + end + end + end end describe 'POST #mark_as_spam' do diff --git a/spec/factories/ci/job_artifacts.rb b/spec/factories/ci/job_artifacts.rb index 82383cfa2b0..a259c5142fc 100644 --- a/spec/factories/ci/job_artifacts.rb +++ b/spec/factories/ci/job_artifacts.rb @@ -13,7 +13,7 @@ FactoryBot.define do end trait :remote_store do - file_store { JobArtifactUploader::Store::REMOTE} + file_store { JobArtifactUploader::Store::REMOTE } end after :build do |artifact| diff --git a/spec/factories/diff_note_positions.rb b/spec/factories/diff_note_positions.rb new file mode 100644 index 00000000000..6e95e306d50 --- /dev/null +++ b/spec/factories/diff_note_positions.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :diff_note_position do + association :note, factory: :diff_note_on_merge_request + line_code { note.line_code } + position { note.position } + diff_type { :head } + end +end diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb index d7f12411a93..cee9b6d50ba 100644 --- a/spec/features/projects/environments/environments_spec.rb +++ b/spec/features/projects/environments/environments_spec.rb @@ -399,10 +399,12 @@ describe 'Environments page', :js do describe 'environments folders' do before do - create(:environment, project: project, + create(:environment, :will_auto_stop, + project: project, name: 'staging/review-1', state: :available) - create(:environment, project: project, + create(:environment, :will_auto_stop, + project: project, name: 'staging/review-2', state: :available) end @@ -420,6 +422,14 @@ describe 'Environments page', :js do expect(page).to have_content 'review-1' expect(page).to have_content 'review-2' + within('.ci-table') do + within('.gl-responsive-table-row:nth-child(3)') do + expect(find('.js-auto-stop').text).not_to be_empty + end + within('.gl-responsive-table-row:nth-child(4)') do + expect(find('.js-auto-stop').text).not_to be_empty + end + end end end diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js index 3aad4c87237..870e47edde0 100644 --- a/spec/frontend/monitoring/components/charts/time_series_spec.js +++ b/spec/frontend/monitoring/components/charts/time_series_spec.js @@ -50,6 +50,7 @@ describe('Time series component', () => { propsData: { graphData: { ...graphData, type }, deploymentData: store.state.monitoringDashboard.deploymentData, + annotations: store.state.monitoringDashboard.annotations, projectPath: `${mockHost}${mockProjectDir}`, }, store, diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js index d6faec29b65..c34a5afceb0 100644 --- a/spec/frontend/monitoring/store/actions_spec.js +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -16,6 +16,7 @@ import { fetchDeploymentsData, fetchEnvironmentsData, fetchDashboardData, + fetchAnnotations, fetchPrometheusMetric, setInitialState, filterEnvironments, @@ -24,10 +25,12 @@ import { } from '~/monitoring/stores/actions'; import { gqClient, parseEnvironmentsResponse } from '~/monitoring/stores/utils'; import getEnvironments from '~/monitoring/queries/getEnvironments.query.graphql'; +import getAnnotations from '~/monitoring/queries/getAnnotations.query.graphql'; import storeState from '~/monitoring/stores/state'; import { deploymentData, environmentData, + annotationsData, metricsDashboardResponse, metricsDashboardViewModel, dashboardGitResponse, @@ -120,17 +123,15 @@ describe('Monitoring store actions', () => { }); it('setting SET_ENVIRONMENTS_FILTER should dispatch fetchEnvironmentsData', () => { - jest.spyOn(gqClient, 'mutate').mockReturnValue( - Promise.resolve({ - data: { - project: { - data: { - environments: [], - }, + jest.spyOn(gqClient, 'mutate').mockReturnValue({ + data: { + project: { + data: { + environments: [], }, }, - }), - ); + }, + }); return testAction( filterEnvironments, @@ -180,17 +181,15 @@ describe('Monitoring store actions', () => { }); it('dispatches receiveEnvironmentsDataSuccess on success', () => { - jest.spyOn(gqClient, 'mutate').mockReturnValue( - Promise.resolve({ - data: { - project: { - data: { - environments: environmentData, - }, + jest.spyOn(gqClient, 'mutate').mockResolvedValue({ + data: { + project: { + data: { + environments: environmentData, }, }, - }), - ); + }, + }); return testAction( fetchEnvironmentsData, @@ -208,7 +207,7 @@ describe('Monitoring store actions', () => { }); it('dispatches receiveEnvironmentsDataFailure on error', () => { - jest.spyOn(gqClient, 'mutate').mockReturnValue(Promise.reject()); + jest.spyOn(gqClient, 'mutate').mockRejectedValue({}); return testAction( fetchEnvironmentsData, @@ -220,6 +219,80 @@ describe('Monitoring store actions', () => { }); }); + describe('fetchAnnotations', () => { + const { state } = store; + state.projectPath = 'gitlab-org/gitlab-test'; + state.currentEnvironmentName = 'production'; + state.currentDashboard = '.gitlab/dashboards/custom_dashboard.yml'; + + afterEach(() => { + resetStore(store); + }); + + it('fetches annotations data and dispatches receiveAnnotationsSuccess', () => { + const mockMutate = jest.spyOn(gqClient, 'mutate'); + const mutationVariables = { + mutation: getAnnotations, + variables: { + projectPath: state.projectPath, + environmentName: state.currentEnvironmentName, + dashboardId: state.currentDashboard, + }, + }; + + mockMutate.mockResolvedValue({ + data: { + project: { + environment: { + metricDashboard: { + annotations: annotationsData, + }, + }, + }, + }, + }); + + return testAction( + fetchAnnotations, + null, + state, + [], + [ + { type: 'requestAnnotations' }, + { type: 'receiveAnnotationsSuccess', payload: annotationsData }, + ], + () => { + expect(mockMutate).toHaveBeenCalledWith(mutationVariables); + }, + ); + }); + + it('dispatches receiveAnnotationsFailure if the annotations API call fails', () => { + const mockMutate = jest.spyOn(gqClient, 'mutate'); + const mutationVariables = { + mutation: getAnnotations, + variables: { + projectPath: state.projectPath, + environmentName: state.currentEnvironmentName, + dashboardId: state.currentDashboard, + }, + }; + + mockMutate.mockRejectedValue({}); + + return testAction( + fetchAnnotations, + null, + state, + [], + [{ type: 'requestAnnotations' }, { type: 'receiveAnnotationsFailure' }], + () => { + expect(mockMutate).toHaveBeenCalledWith(mutationVariables); + }, + ); + }); + }); + describe('Set initial state', () => { let mockedState; beforeEach(() => { diff --git a/spec/frontend/smart_interval_spec.js b/spec/frontend/smart_interval_spec.js new file mode 100644 index 00000000000..b32ac99e4e4 --- /dev/null +++ b/spec/frontend/smart_interval_spec.js @@ -0,0 +1,197 @@ +import $ from 'jquery'; +import { assignIn } from 'lodash'; +import waitForPromises from 'helpers/wait_for_promises'; +import SmartInterval from '~/smart_interval'; + +jest.useFakeTimers(); + +let interval; + +describe('SmartInterval', () => { + const DEFAULT_MAX_INTERVAL = 100; + const DEFAULT_STARTING_INTERVAL = 5; + const DEFAULT_INCREMENT_FACTOR = 2; + + function createDefaultSmartInterval(config) { + const defaultParams = { + callback: () => Promise.resolve(), + startingInterval: DEFAULT_STARTING_INTERVAL, + maxInterval: DEFAULT_MAX_INTERVAL, + incrementByFactorOf: DEFAULT_INCREMENT_FACTOR, + lazyStart: false, + immediateExecution: false, + hiddenInterval: null, + }; + + if (config) { + assignIn(defaultParams, config); + } + + return new SmartInterval(defaultParams); + } + + afterEach(() => { + interval.destroy(); + }); + + describe('Increment Interval', () => { + it('should increment the interval delay', () => { + interval = createDefaultSmartInterval(); + + jest.runOnlyPendingTimers(); + + return waitForPromises().then(() => { + const intervalConfig = interval.cfg; + const iterationCount = 4; + const maxIntervalAfterIterations = + intervalConfig.startingInterval * intervalConfig.incrementByFactorOf ** iterationCount; + const currentInterval = interval.getCurrentInterval(); + + // Provide some flexibility for performance of testing environment + expect(currentInterval).toBeGreaterThan(intervalConfig.startingInterval); + expect(currentInterval).toBeLessThanOrEqual(maxIntervalAfterIterations); + }); + }); + + it('should not increment past maxInterval', () => { + interval = createDefaultSmartInterval({ maxInterval: DEFAULT_STARTING_INTERVAL }); + + jest.runOnlyPendingTimers(); + + return waitForPromises().then(() => { + const currentInterval = interval.getCurrentInterval(); + + expect(currentInterval).toBe(interval.cfg.maxInterval); + }); + }); + + it('does not increment while waiting for callback', () => { + interval = createDefaultSmartInterval({ + callback: () => new Promise($.noop), + }); + + jest.runOnlyPendingTimers(); + + return waitForPromises().then(() => { + const oneInterval = interval.cfg.startingInterval * DEFAULT_INCREMENT_FACTOR; + + expect(interval.getCurrentInterval()).toEqual(oneInterval); + }); + }); + }); + + describe('Public methods', () => { + beforeEach(() => { + interval = createDefaultSmartInterval(); + }); + + it('should cancel an interval', () => { + jest.runOnlyPendingTimers(); + + interval.cancel(); + + return waitForPromises().then(() => { + const { intervalId } = interval.state; + const currentInterval = interval.getCurrentInterval(); + const intervalLowerLimit = interval.cfg.startingInterval; + + expect(intervalId).toBeUndefined(); + expect(currentInterval).toBe(intervalLowerLimit); + }); + }); + + it('should resume an interval', () => { + jest.runOnlyPendingTimers(); + + interval.cancel(); + + interval.resume(); + + return waitForPromises().then(() => { + const { intervalId } = interval.state; + + expect(intervalId).toBeTruthy(); + }); + }); + }); + + describe('DOM Events', () => { + beforeEach(() => { + // This ensures DOM and DOM events are initialized for these specs. + setFixtures('<div></div>'); + + interval = createDefaultSmartInterval(); + }); + + it('should pause when page is not visible', () => { + jest.runOnlyPendingTimers(); + + return waitForPromises().then(() => { + expect(interval.state.intervalId).toBeTruthy(); + + // simulates triggering of visibilitychange event + interval.onVisibilityChange({ target: { visibilityState: 'hidden' } }); + + expect(interval.state.intervalId).toBeUndefined(); + }); + }); + + it('should change to the hidden interval when page is not visible', () => { + interval.destroy(); + + const HIDDEN_INTERVAL = 1500; + interval = createDefaultSmartInterval({ hiddenInterval: HIDDEN_INTERVAL }); + + jest.runOnlyPendingTimers(); + + return waitForPromises().then(() => { + expect(interval.state.intervalId).toBeTruthy(); + expect( + interval.getCurrentInterval() >= DEFAULT_STARTING_INTERVAL && + interval.getCurrentInterval() <= DEFAULT_MAX_INTERVAL, + ).toBeTruthy(); + + // simulates triggering of visibilitychange event + interval.onVisibilityChange({ target: { visibilityState: 'hidden' } }); + + expect(interval.state.intervalId).toBeTruthy(); + expect(interval.getCurrentInterval()).toBe(HIDDEN_INTERVAL); + }); + }); + + it('should resume when page is becomes visible at the previous interval', () => { + jest.runOnlyPendingTimers(); + + return waitForPromises().then(() => { + expect(interval.state.intervalId).toBeTruthy(); + + // simulates triggering of visibilitychange event + interval.onVisibilityChange({ target: { visibilityState: 'hidden' } }); + + expect(interval.state.intervalId).toBeUndefined(); + + // simulates triggering of visibilitychange event + interval.onVisibilityChange({ target: { visibilityState: 'visible' } }); + + expect(interval.state.intervalId).toBeTruthy(); + }); + }); + + it('should cancel on page unload', () => { + jest.runOnlyPendingTimers(); + + return waitForPromises().then(() => { + $(document).triggerHandler('beforeunload'); + + expect(interval.state.intervalId).toBeUndefined(); + expect(interval.getCurrentInterval()).toBe(interval.cfg.startingInterval); + }); + }); + + it('should execute callback before first interval', () => { + interval = createDefaultSmartInterval({ immediateExecution: true }); + + expect(interval.cfg.immediateExecution).toBeFalsy(); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/components/static_site_editor_spec.js b/spec/frontend/static_site_editor/components/static_site_editor_spec.js index a40f8edbeb2..2c4fa0e061a 100644 --- a/spec/frontend/static_site_editor/components/static_site_editor_spec.js +++ b/spec/frontend/static_site_editor/components/static_site_editor_spec.js @@ -30,7 +30,6 @@ describe('StaticSiteEditor', () => { store = new Vuex.Store({ state: createState(initialState), getters: { - isContentLoaded: () => false, contentChanged: () => false, ...getters, }, @@ -43,9 +42,11 @@ describe('StaticSiteEditor', () => { }; const buildContentLoadedStore = ({ initialState, getters } = {}) => { buildStore({ - initialState, + initialState: { + isContentLoaded: true, + ...initialState, + }, getters: { - isContentLoaded: () => true, ...getters, }, }); @@ -85,7 +86,7 @@ describe('StaticSiteEditor', () => { const content = 'edit area content'; beforeEach(() => { - buildStore({ initialState: { content }, getters: { isContentLoaded: () => true } }); + buildContentLoadedStore({ initialState: { content } }); buildWrapper(); }); diff --git a/spec/frontend/static_site_editor/store/getters_spec.js b/spec/frontend/static_site_editor/store/getters_spec.js index 1b482db9366..5793e344784 100644 --- a/spec/frontend/static_site_editor/store/getters_spec.js +++ b/spec/frontend/static_site_editor/store/getters_spec.js @@ -1,18 +1,8 @@ import createState from '~/static_site_editor/store/state'; -import { isContentLoaded, contentChanged } from '~/static_site_editor/store/getters'; +import { contentChanged } from '~/static_site_editor/store/getters'; import { sourceContent as content } from '../mock_data'; describe('Static Site Editor Store getters', () => { - describe('isContentLoaded', () => { - it('returns true when originalContent is not empty', () => { - expect(isContentLoaded(createState({ originalContent: content }))).toBe(true); - }); - - it('returns false when originalContent is empty', () => { - expect(isContentLoaded(createState({ originalContent: '' }))).toBe(false); - }); - }); - describe('contentChanged', () => { it('returns true when content and originalContent are different', () => { const state = createState({ content, originalContent: 'something else' }); diff --git a/spec/frontend/static_site_editor/store/mutations_spec.js b/spec/frontend/static_site_editor/store/mutations_spec.js index 1fd687eed4a..0b213c11a04 100644 --- a/spec/frontend/static_site_editor/store/mutations_spec.js +++ b/spec/frontend/static_site_editor/store/mutations_spec.js @@ -19,6 +19,7 @@ describe('Static Site Editor Store mutations', () => { mutation | stateProperty | payload | expectedValue ${types.LOAD_CONTENT} | ${'isLoadingContent'} | ${undefined} | ${true} ${types.RECEIVE_CONTENT_SUCCESS} | ${'isLoadingContent'} | ${contentLoadedPayload} | ${false} + ${types.RECEIVE_CONTENT_SUCCESS} | ${'isContentLoaded'} | ${contentLoadedPayload} | ${true} ${types.RECEIVE_CONTENT_SUCCESS} | ${'title'} | ${contentLoadedPayload} | ${title} ${types.RECEIVE_CONTENT_SUCCESS} | ${'content'} | ${contentLoadedPayload} | ${content} ${types.RECEIVE_CONTENT_SUCCESS} | ${'originalContent'} | ${contentLoadedPayload} | ${content} diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js index ef95cb1b8f2..e022f68fdec 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -273,25 +273,6 @@ describe('mrWidgetOptions', () => { }; }); - it('should not tell service to check status if document is not visible', () => { - Object.defineProperty(document, 'visibilityState', { - value: 'hidden', - configurable: true, - }); - vm.checkStatus(cb); - - return vm.$nextTick().then(() => { - expect(vm.service.checkStatus).not.toHaveBeenCalled(); - expect(vm.mr.setData).not.toHaveBeenCalled(); - expect(vm.handleNotification).not.toHaveBeenCalled(); - expect(isCbExecuted).toBeFalsy(); - Object.defineProperty(document, 'visibilityState', { - value: 'visible', - configurable: true, - }); - }); - }); - it('should tell service to check status if document is visible', () => { vm.checkStatus(cb); diff --git a/spec/javascripts/smart_interval_spec.js b/spec/javascripts/smart_interval_spec.js deleted file mode 100644 index 0dc9ee9d79a..00000000000 --- a/spec/javascripts/smart_interval_spec.js +++ /dev/null @@ -1,234 +0,0 @@ -import $ from 'jquery'; -import { assignIn } from 'lodash'; -import waitForPromises from 'spec/helpers/wait_for_promises'; -import SmartInterval from '~/smart_interval'; - -describe('SmartInterval', function() { - const DEFAULT_MAX_INTERVAL = 100; - const DEFAULT_STARTING_INTERVAL = 5; - const DEFAULT_SHORT_TIMEOUT = 75; - const DEFAULT_INCREMENT_FACTOR = 2; - - function createDefaultSmartInterval(config) { - const defaultParams = { - callback: () => Promise.resolve(), - startingInterval: DEFAULT_STARTING_INTERVAL, - maxInterval: DEFAULT_MAX_INTERVAL, - incrementByFactorOf: DEFAULT_INCREMENT_FACTOR, - lazyStart: false, - immediateExecution: false, - hiddenInterval: null, - }; - - if (config) { - assignIn(defaultParams, config); - } - - return new SmartInterval(defaultParams); - } - - beforeEach(() => { - jasmine.clock().install(); - }); - - afterEach(() => { - jasmine.clock().uninstall(); - }); - - describe('Increment Interval', function() { - it('should increment the interval delay', done => { - const smartInterval = createDefaultSmartInterval(); - - jasmine.clock().tick(DEFAULT_SHORT_TIMEOUT); - - waitForPromises() - .then(() => { - const intervalConfig = smartInterval.cfg; - const iterationCount = 4; - const maxIntervalAfterIterations = - intervalConfig.startingInterval * intervalConfig.incrementByFactorOf ** iterationCount; - const currentInterval = smartInterval.getCurrentInterval(); - - // Provide some flexibility for performance of testing environment - expect(currentInterval).toBeGreaterThan(intervalConfig.startingInterval); - expect(currentInterval).toBeLessThanOrEqual(maxIntervalAfterIterations); - }) - .then(done) - .catch(done.fail); - }); - - it('should not increment past maxInterval', done => { - const smartInterval = createDefaultSmartInterval({ maxInterval: DEFAULT_STARTING_INTERVAL }); - - jasmine.clock().tick(DEFAULT_STARTING_INTERVAL); - jasmine.clock().tick(DEFAULT_STARTING_INTERVAL * DEFAULT_INCREMENT_FACTOR); - - waitForPromises() - .then(() => { - const currentInterval = smartInterval.getCurrentInterval(); - - expect(currentInterval).toBe(smartInterval.cfg.maxInterval); - }) - .then(done) - .catch(done.fail); - }); - - it('does not increment while waiting for callback', done => { - const smartInterval = createDefaultSmartInterval({ - callback: () => new Promise($.noop), - }); - - jasmine.clock().tick(DEFAULT_SHORT_TIMEOUT); - - waitForPromises() - .then(() => { - const oneInterval = smartInterval.cfg.startingInterval * DEFAULT_INCREMENT_FACTOR; - - expect(smartInterval.getCurrentInterval()).toEqual(oneInterval); - }) - .then(done) - .catch(done.fail); - }); - }); - - describe('Public methods', function() { - beforeEach(function() { - this.smartInterval = createDefaultSmartInterval(); - }); - - it('should cancel an interval', function(done) { - const interval = this.smartInterval; - - jasmine.clock().tick(DEFAULT_SHORT_TIMEOUT); - - interval.cancel(); - - waitForPromises() - .then(() => { - const { intervalId } = interval.state; - const currentInterval = interval.getCurrentInterval(); - const intervalLowerLimit = interval.cfg.startingInterval; - - expect(intervalId).toBeUndefined(); - expect(currentInterval).toBe(intervalLowerLimit); - }) - .then(done) - .catch(done.fail); - }); - - it('should resume an interval', function(done) { - const interval = this.smartInterval; - - jasmine.clock().tick(DEFAULT_SHORT_TIMEOUT); - - interval.cancel(); - - interval.resume(); - - waitForPromises() - .then(() => { - const { intervalId } = interval.state; - - expect(intervalId).toBeTruthy(); - }) - .then(done) - .catch(done.fail); - }); - }); - - describe('DOM Events', function() { - beforeEach(function() { - // This ensures DOM and DOM events are initialized for these specs. - setFixtures('<div></div>'); - - this.smartInterval = createDefaultSmartInterval(); - }); - - it('should pause when page is not visible', function(done) { - const interval = this.smartInterval; - - jasmine.clock().tick(DEFAULT_SHORT_TIMEOUT); - - waitForPromises() - .then(() => { - expect(interval.state.intervalId).toBeTruthy(); - - // simulates triggering of visibilitychange event - interval.handleVisibilityChange({ target: { visibilityState: 'hidden' } }); - - expect(interval.state.intervalId).toBeUndefined(); - }) - .then(done) - .catch(done.fail); - }); - - it('should change to the hidden interval when page is not visible', done => { - const HIDDEN_INTERVAL = 1500; - const interval = createDefaultSmartInterval({ hiddenInterval: HIDDEN_INTERVAL }); - - jasmine.clock().tick(DEFAULT_SHORT_TIMEOUT); - - waitForPromises() - .then(() => { - expect(interval.state.intervalId).toBeTruthy(); - expect( - interval.getCurrentInterval() >= DEFAULT_STARTING_INTERVAL && - interval.getCurrentInterval() <= DEFAULT_MAX_INTERVAL, - ).toBeTruthy(); - - // simulates triggering of visibilitychange event - interval.handleVisibilityChange({ target: { visibilityState: 'hidden' } }); - - expect(interval.state.intervalId).toBeTruthy(); - expect(interval.getCurrentInterval()).toBe(HIDDEN_INTERVAL); - }) - .then(done) - .catch(done.fail); - }); - - it('should resume when page is becomes visible at the previous interval', function(done) { - const interval = this.smartInterval; - - jasmine.clock().tick(DEFAULT_SHORT_TIMEOUT); - - waitForPromises() - .then(() => { - expect(interval.state.intervalId).toBeTruthy(); - - // simulates triggering of visibilitychange event - interval.handleVisibilityChange({ target: { visibilityState: 'hidden' } }); - - expect(interval.state.intervalId).toBeUndefined(); - - // simulates triggering of visibilitychange event - interval.handleVisibilityChange({ target: { visibilityState: 'visible' } }); - - expect(interval.state.intervalId).toBeTruthy(); - }) - .then(done) - .catch(done.fail); - }); - - it('should cancel on page unload', function(done) { - const interval = this.smartInterval; - - jasmine.clock().tick(DEFAULT_SHORT_TIMEOUT); - - waitForPromises() - .then(() => { - $(document).triggerHandler('beforeunload'); - - expect(interval.state.intervalId).toBeUndefined(); - expect(interval.getCurrentInterval()).toBe(interval.cfg.startingInterval); - }) - .then(done) - .catch(done.fail); - }); - - it('should execute callback before first interval', function() { - const interval = createDefaultSmartInterval({ immediateExecution: true }); - - expect(interval.cfg.immediateExecution).toBeFalsy(); - }); - }); -}); diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb index e11613b202d..6e8a8c03aad 100644 --- a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb @@ -113,28 +113,22 @@ describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gitlab_r end describe 'droppable?' do - where(:idempotent, :duplicate, :feature_enabled) do - # [true, false].repeated_permutation(3) - [[true, true, true], - [true, true, false], - [true, false, true], - [true, false, false], - [false, true, true], - [false, true, false], - [false, false, true], - [false, false, false]] + where(:idempotent, :duplicate) do + # [true, false].repeated_permutation(2) + [[true, true], + [true, false], + [false, true], + [false, false]] end with_them do before do allow(AuthorizedProjectsWorker).to receive(:idempotent?).and_return(idempotent) allow(duplicate_job).to receive(:duplicate?).and_return(duplicate) - allow(Gitlab::SidekiqMiddleware::DuplicateJobs) - .to receive(:drop_duplicates?).with(queue).and_return(feature_enabled) end it 'is droppable when all conditions are met' do - if idempotent && duplicate && feature_enabled + if idempotent && duplicate expect(duplicate_job).to be_droppable else expect(duplicate_job).not_to be_droppable diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs_spec.rb deleted file mode 100644 index fa5938f470b..00000000000 --- a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs_spec.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe Gitlab::SidekiqMiddleware::DuplicateJobs do - using RSpec::Parameterized::TableSyntax - - describe '.drop_duplicates?' do - where(:global_feature_enabled, :selected_queue_enabled, :queue, :expected) do - true | true | described_class::DROPPABLE_QUEUES.first | true - true | true | "other_queue" | true - true | false | described_class::DROPPABLE_QUEUES.first | true - true | false | "other_queue" | true - false | true | described_class::DROPPABLE_QUEUES.first | true - false | true | "other_queue" | false - false | false | described_class::DROPPABLE_QUEUES.first | false - false | false | "other_queue" | false - end - - with_them do - before do - stub_feature_flags(drop_duplicate_sidekiq_jobs: global_feature_enabled, - drop_duplicate_sidekiq_jobs_for_queue: selected_queue_enabled) - end - - it "allows dropping jobs when expected" do - expect(described_class.drop_duplicates?(queue)).to be(expected) - end - end - end -end diff --git a/spec/mailers/emails/pages_domains_spec.rb b/spec/mailers/emails/pages_domains_spec.rb index 78887cef7ab..5029a17e4e5 100644 --- a/spec/mailers/emails/pages_domains_spec.rb +++ b/spec/mailers/emails/pages_domains_spec.rb @@ -23,13 +23,20 @@ describe Emails::PagesDomains do is_expected.to have_subject(email_subject) is_expected.to have_body_text(project.human_name) is_expected.to have_body_text(domain.domain) - is_expected.to have_body_text domain.url is_expected.to have_body_text project_pages_domain_url(project, domain) - is_expected.to have_body_text help_page_url('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: link_anchor) end end end + shared_examples 'a pages domain verification email' do + it_behaves_like 'a pages domain email' + + it 'has the expected content' do + is_expected.to have_body_text domain.url + is_expected.to have_body_text help_page_url('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: link_anchor) + end + end + shared_examples 'notification about upcoming domain removal' do context 'when domain is not scheduled for removal' do it 'asks user to remove it' do @@ -56,7 +63,7 @@ describe Emails::PagesDomains do subject { Notify.pages_domain_enabled_email(domain, user) } - it_behaves_like 'a pages domain email' + it_behaves_like 'a pages domain verification email' it { is_expected.to have_body_text 'has been enabled' } end @@ -67,7 +74,7 @@ describe Emails::PagesDomains do subject { Notify.pages_domain_disabled_email(domain, user) } - it_behaves_like 'a pages domain email' + it_behaves_like 'a pages domain verification email' it_behaves_like 'notification about upcoming domain removal' @@ -80,7 +87,7 @@ describe Emails::PagesDomains do subject { Notify.pages_domain_verification_succeeded_email(domain, user) } - it_behaves_like 'a pages domain email' + it_behaves_like 'a pages domain verification email' it { is_expected.to have_body_text 'successfully verified' } end @@ -94,10 +101,18 @@ describe Emails::PagesDomains do it_behaves_like 'a pages domain email' it_behaves_like 'notification about upcoming domain removal' + end + + describe '#pages_domain_auto_ssl_failed_email' do + let(:email_subject) { "#{project.path} | ACTION REQUIRED: Something went wrong while obtaining the Let's Encrypt certificate for GitLab Pages domain '#{domain.domain}'" } + + subject { Notify.pages_domain_auto_ssl_failed_email(domain, user) } + + it_behaves_like 'a pages domain email' - it 'says verification has failed and when the domain is enabled until' do - is_expected.to have_body_text 'Verification has failed' - is_expected.to have_body_text domain.enabled_until.strftime('%F %T') + it 'says that we failed to obtain certificate' do + is_expected.to have_body_text "Something went wrong while obtaining the Let's Encrypt certificate." + is_expected.to have_body_text help_page_url('user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md', anchor: 'troubleshooting') end end end diff --git a/spec/models/ci/job_artifact_spec.rb b/spec/models/ci/job_artifact_spec.rb index 6f6ff3704b4..80b619ed2b1 100644 --- a/spec/models/ci/job_artifact_spec.rb +++ b/spec/models/ci/job_artifact_spec.rb @@ -349,16 +349,13 @@ describe Ci::JobArtifact do end describe 'file is being stored' do - subject { create(:ci_job_artifact, :archive) } - context 'when object has nil store' do - before do - subject.update_column(:file_store, nil) - subject.reload - end - it 'is stored locally' do - expect(subject.file_store).to be(nil) + subject = build(:ci_job_artifact, :archive, file_store: nil) + + subject.save + + expect(subject.file_store).to be(ObjectStorage::Store::LOCAL) expect(subject.file).to be_file_storage expect(subject.file.object_store).to eq(ObjectStorage::Store::LOCAL) end @@ -366,6 +363,10 @@ describe Ci::JobArtifact do context 'when existing object has local store' do it 'is stored locally' do + subject = build(:ci_job_artifact, :archive) + + subject.save + expect(subject.file_store).to be(ObjectStorage::Store::LOCAL) expect(subject.file).to be_file_storage expect(subject.file.object_store).to eq(ObjectStorage::Store::LOCAL) @@ -379,6 +380,10 @@ describe Ci::JobArtifact do context 'when file is stored' do it 'is stored remotely' do + subject = build(:ci_job_artifact, :archive) + + subject.save + expect(subject.file_store).to eq(ObjectStorage::Store::REMOTE) expect(subject.file).not_to be_file_storage expect(subject.file.object_store).to eq(ObjectStorage::Store::REMOTE) diff --git a/spec/models/diff_note_position_spec.rb b/spec/models/diff_note_position_spec.rb new file mode 100644 index 00000000000..a00ba35feef --- /dev/null +++ b/spec/models/diff_note_position_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe DiffNotePosition, type: :model do + it 'has a position attribute' do + diff_position = build(:diff_position) + line_code = 'bd4b7bfff3a247ccf6e3371c41ec018a55230bcc_534_521' + diff_note_position = build(:diff_note_position, line_code: line_code, position: diff_position) + + expect(diff_note_position.position).to eq(diff_position) + expect(diff_note_position.line_code).to eq(line_code) + expect(diff_note_position.diff_content_type).to eq('text') + end + + it 'unique by note_id and diff type' do + existing_diff_note_position = create(:diff_note_position) + diff_note_position = build(:diff_note_position, note: existing_diff_note_position.note) + + expect { diff_note_position.save! }.to raise_error(ActiveRecord::RecordNotUnique) + end +end diff --git a/spec/requests/api/group_clusters_spec.rb b/spec/requests/api/group_clusters_spec.rb index d3bd84f1604..fade54f6b11 100644 --- a/spec/requests/api/group_clusters_spec.rb +++ b/spec/requests/api/group_clusters_spec.rb @@ -157,6 +157,7 @@ describe API::GroupClusters do let(:api_url) { 'https://kubernetes.example.com' } let(:authorization_type) { 'rbac' } + let(:management_project_id) { create(:project, group: group).id } let(:platform_kubernetes_attributes) do { @@ -171,7 +172,8 @@ describe API::GroupClusters do name: 'test-cluster', domain: 'domain.example.com', managed: false, - platform_kubernetes_attributes: platform_kubernetes_attributes + platform_kubernetes_attributes: platform_kubernetes_attributes, + management_project_id: management_project_id } end @@ -203,6 +205,7 @@ describe API::GroupClusters do expect(cluster_result.name).to eq('test-cluster') expect(cluster_result.domain).to eq('domain.example.com') expect(cluster_result.managed).to be_falsy + expect(cluster_result.management_project_id).to eq management_project_id expect(platform_kubernetes.rbac?).to be_truthy expect(platform_kubernetes.api_url).to eq(api_url) expect(platform_kubernetes.token).to eq('sample-token') @@ -234,6 +237,18 @@ describe API::GroupClusters do end end + context 'current user does not have access to management_project_id' do + let(:management_project_id) { create(:project).id } + + it 'responds with 400' do + expect(response).to have_gitlab_http_status(:bad_request) + end + + it 'returns validation errors' do + expect(json_response['message']['management_project_id'].first).to match('don\'t have permission') + end + end + context 'with invalid params' do let(:api_url) { 'invalid_api_url' } diff --git a/spec/requests/api/project_clusters_spec.rb b/spec/requests/api/project_clusters_spec.rb index 648577dce8d..ed899e830e1 100644 --- a/spec/requests/api/project_clusters_spec.rb +++ b/spec/requests/api/project_clusters_spec.rb @@ -150,6 +150,12 @@ describe API::ProjectClusters do let(:api_url) { 'https://kubernetes.example.com' } let(:namespace) { project.path } let(:authorization_type) { 'rbac' } + let(:management_project) { create(:project, namespace: project.namespace) } + let(:management_project_id) { management_project.id } + + before do + management_project.add_maintainer(current_user) + end let(:platform_kubernetes_attributes) do { @@ -165,7 +171,8 @@ describe API::ProjectClusters do name: 'test-cluster', domain: 'domain.example.com', managed: false, - platform_kubernetes_attributes: platform_kubernetes_attributes + platform_kubernetes_attributes: platform_kubernetes_attributes, + management_project_id: management_project_id } end @@ -194,6 +201,7 @@ describe API::ProjectClusters do expect(cluster_result.name).to eq('test-cluster') expect(cluster_result.domain).to eq('domain.example.com') expect(cluster_result.managed).to be_falsy + expect(cluster_result.management_project_id).to eq management_project_id expect(platform_kubernetes.rbac?).to be_truthy expect(platform_kubernetes.api_url).to eq(api_url) expect(platform_kubernetes.namespace).to eq(namespace) @@ -227,6 +235,18 @@ describe API::ProjectClusters do end end + context 'current user does not have access to management_project_id' do + let(:management_project_id) { create(:project).id } + + it 'responds with 400' do + expect(response).to have_gitlab_http_status(:bad_request) + end + + it 'returns validation errors' do + expect(json_response['message']['management_project_id'].first).to match('don\'t have permission') + end + end + context 'with invalid params' do let(:namespace) { 'invalid_namespace' } diff --git a/spec/rubocop/cop/performance/ar_count_each_spec.rb b/spec/rubocop/cop/performance/ar_count_each_spec.rb new file mode 100644 index 00000000000..f934a1fde48 --- /dev/null +++ b/spec/rubocop/cop/performance/ar_count_each_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require_relative '../../../support/helpers/expect_offense' +require_relative '../../../../rubocop/cop/performance/ar_count_each.rb' + +describe RuboCop::Cop::Performance::ARCountEach do + include CopHelper + include ExpectOffense + + subject(:cop) { described_class.new } + + context 'when it is not haml file' do + it 'does not flag it as an offense' do + expect(subject).to receive(:in_haml_file?).with(anything).at_least(:once).and_return(false) + + expect_no_offenses <<~SOURCE + show(@users.count) + @users.each { |user| display(user) } + SOURCE + end + end + + context 'when it is haml file' do + before do + expect(subject).to receive(:in_haml_file?).with(anything).at_least(:once).and_return(true) + end + + context 'when the same object uses count and each' do + it 'flags it as an offense' do + expect_offense <<~SOURCE + show(@users.count) + ^^^^^^^^^^^^ If @users is AR relation, avoid `@users.count ...; @users.each... `, this will trigger two queries. Use `@users.load.size ...; @users.each... ` instead. If @users is an array, try to use @users.size. + @users.each { |user| display(user) } + SOURCE + + expect(cop.offenses.map(&:cop_name)).to contain_exactly('Performance/ARCountEach') + end + end + + context 'when different object uses count and each' do + it 'does not flag it as an offense' do + expect_no_offenses <<~SOURCE + show(@emails.count) + @users.each { |user| display(user) } + SOURCE + end + end + + context 'when just using count without each' do + it 'does not flag it as an offense' do + expect_no_offenses '@users.count' + end + end + + context 'when just using each without count' do + it 'does not flag it as an offense' do + expect_no_offenses '@users.each { |user| display(user) }' + end + end + end +end diff --git a/spec/services/application_settings/update_service_spec.rb b/spec/services/application_settings/update_service_spec.rb index 6e1fdb7aad0..069572e4dff 100644 --- a/spec/services/application_settings/update_service_spec.rb +++ b/spec/services/application_settings/update_service_spec.rb @@ -334,4 +334,20 @@ describe ApplicationSettings::UpdateService do expect(application_settings.protected_paths).to eq(['/users/password', '/users/sign_in']) end end + + context 'when issues_create_limit is passsed' do + let(:params) do + { + issues_create_limit: 600 + } + end + + it 'updates issues_create_limit value' do + subject.execute + + application_settings.reload + + expect(application_settings.issues_create_limit).to eq(600) + end + end end diff --git a/spec/services/clusters/create_service_spec.rb b/spec/services/clusters/create_service_spec.rb index ecf0a9c9dce..3dd25be2a3d 100644 --- a/spec/services/clusters/create_service_spec.rb +++ b/spec/services/clusters/create_service_spec.rb @@ -59,4 +59,92 @@ describe Clusters::CreateService do end end end + + context 'when params includes :management_project_id' do + subject(:cluster) { described_class.new(user, params).execute(access_token: access_token) } + + let(:params) do + { + name: 'test-cluster', + provider_type: :gcp, + provider_gcp_attributes: { + gcp_project_id: 'gcp-project', + zone: 'us-central1-a', + num_nodes: 1, + machine_type: 'machine_type-a', + legacy_abac: 'true' + }, + clusterable: clusterable, + management_project_id: management_project_id + } + end + + let(:clusterable) { project } + let(:management_project_id) { management_project.id } + let(:management_project_namespace) { project.namespace } + let(:management_project) { create(:project, namespace: management_project_namespace) } + + shared_examples 'invalid project or cluster permissions' do + it 'does not persist the cluster and adds errors' do + expect(cluster).not_to be_persisted + + expect(cluster.errors[:management_project_id]).to include('Project does not exist or you don\'t have permission to perform this action') + end + end + + shared_examples 'setting a management project' do + context 'when user is authorized to adminster manangement_project' do + before do + management_project.add_maintainer(user) + end + + it 'persists the cluster' do + expect(cluster).to be_persisted + + expect(cluster.management_project).to eq(management_project) + end + end + + context 'when user is not authorized to adminster manangement_project' do + include_examples 'invalid project or cluster permissions' + end + end + + shared_examples 'setting a management project outside of scope' do + context 'when manangement_project is outside of the namespace scope' do + let(:management_project_namespace) { create(:group) } + + it 'does not persist the cluster' do + expect(cluster).not_to be_persisted + + expect(cluster.errors[:management_project_id]).to include('Project does not exist or you don\'t have permission to perform this action') + end + end + end + + context 'management_project is non-existent' do + let(:management_project_id) { 0 } + + include_examples 'invalid project or cluster permissions' + end + + context 'project cluster' do + include_examples 'setting a management project' + include_examples 'setting a management project outside of scope' + end + + context 'group cluster' do + let(:management_project_namespace) { create(:group) } + let(:clusterable) { management_project_namespace } + + include_examples 'setting a management project' + include_examples 'setting a management project outside of scope' + end + + context 'instance cluster' do + let(:clusterable) { Clusters::Instance.new } + + include_examples 'setting a management project' + end + end end diff --git a/spec/services/clusters/management/validate_management_project_permissions_service_spec.rb b/spec/services/clusters/management/validate_management_project_permissions_service_spec.rb new file mode 100644 index 00000000000..1bcebe2e2ac --- /dev/null +++ b/spec/services/clusters/management/validate_management_project_permissions_service_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Clusters::Management::ValidateManagementProjectPermissionsService do + describe '#execute' do + subject { described_class.new(user).execute(cluster, management_project_id) } + + let(:cluster) { build(:cluster, :project, projects: [create(:project)]) } + let(:user) { create(:user) } + + context 'when management_project_id is nil' do + let(:management_project_id) { nil } + + it { is_expected.to be true } + end + + context 'when management_project_id is not nil' do + let(:management_project_id) { management_project.id } + let(:management_project_namespace) { create(:group) } + let(:management_project) { create(:project, namespace: management_project_namespace) } + + context 'when management_project does not exist' do + let(:management_project_id) { 0 } + + it 'adds errors to the cluster and returns false' do + is_expected.to eq false + + expect(cluster.errors[:management_project_id]).to include('Project does not exist or you don\'t have permission to perform this action') + end + end + + shared_examples 'management project is in scope' do + context 'when user is authorized to administer manangement_project' do + before do + management_project.add_maintainer(user) + end + + it 'adds no error and returns true' do + is_expected.to eq true + + expect(cluster.errors).to be_empty + end + end + + context 'when user is not authorized to adminster manangement_project' do + it 'adds an error and returns false' do + is_expected.to eq false + + expect(cluster.errors[:management_project_id]).to include('Project does not exist or you don\'t have permission to perform this action') + end + end + end + + shared_examples 'management project is out of scope' do + context 'when manangement_project is outside of the namespace scope' do + let(:management_project_namespace) { create(:group) } + + it 'adds an error and returns false' do + is_expected.to eq false + + expect(cluster.errors[:management_project_id]).to include('Project does not exist or you don\'t have permission to perform this action') + end + end + end + + context 'project cluster' do + let(:cluster) { build(:cluster, :project, projects: [create(:project, namespace: management_project_namespace)]) } + + include_examples 'management project is in scope' + include_examples 'management project is out of scope' + end + + context 'group cluster' do + let(:cluster) { build(:cluster, :group, groups: [management_project_namespace]) } + + include_examples 'management project is in scope' + include_examples 'management project is out of scope' + end + + context 'instance cluster' do + let(:cluster) { build(:cluster, :instance) } + + include_examples 'management project is in scope' + end + end + end +end diff --git a/spec/services/environments/auto_stop_service_spec.rb b/spec/services/environments/auto_stop_service_spec.rb index 3620bf8fe87..b34d15889d3 100644 --- a/spec/services/environments/auto_stop_service_spec.rb +++ b/spec/services/environments/auto_stop_service_spec.rb @@ -40,18 +40,6 @@ describe Environments::AutoStopService, :clean_gitlab_redis_shared_state do expect(Ci::Build.where(name: 'stop_review_app').map(&:status).uniq).to eq(['pending']) end - context 'when auto_stop_environments feature flag is disabled' do - before do - stub_feature_flags(auto_stop_environments: false) - end - - it 'does not execute Ci::StopEnvironmentsService' do - expect(Ci::StopEnvironmentsService).not_to receive(:execute_in_batch) - - subject - end - end - context 'when the other sidekiq worker has already been running' do before do stub_exclusive_lease_taken(described_class::EXCLUSIVE_LOCK_KEY) diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 86f37e9204c..163ca0b9bc3 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -2604,6 +2604,7 @@ describe NotificationService, :mailer do pages_domain_disabled pages_domain_verification_succeeded pages_domain_verification_failed + pages_domain_auto_ssl_failed ].each do |sym| describe "##{sym}" do subject(:notify!) { notification.send(sym, domain) } diff --git a/spec/services/pages_domains/obtain_lets_encrypt_certificate_service_spec.rb b/spec/services/pages_domains/obtain_lets_encrypt_certificate_service_spec.rb index 163276db7e6..63fd0978c97 100644 --- a/spec/services/pages_domains/obtain_lets_encrypt_certificate_service_spec.rb +++ b/spec/services/pages_domains/obtain_lets_encrypt_certificate_service_spec.rb @@ -180,5 +180,13 @@ describe PagesDomains::ObtainLetsEncryptCertificateService do expect(PagesDomainAcmeOrder.find_by_id(existing_order.id)).to be_nil end + + it 'sends notification' do + expect_next_instance_of(NotificationService) do |notification_service| + expect(notification_service).to receive(:pages_domain_auto_ssl_failed).with(pages_domain) + end + + service.execute + end end end diff --git a/spec/uploaders/records_uploads_spec.rb b/spec/uploaders/records_uploads_spec.rb index 71eff23c77c..140595e58ad 100644 --- a/spec/uploaders/records_uploads_spec.rb +++ b/spec/uploaders/records_uploads_spec.rb @@ -78,7 +78,8 @@ describe RecordsUploads do path: File.join('uploads', 'rails_sample.jpg'), size: 512.kilobytes, model: build_stubbed(:user), - uploader: uploader.class.to_s + uploader: uploader.class.to_s, + store: ::ObjectStorage::Store::LOCAL ) uploader.upload = existing @@ -98,7 +99,8 @@ describe RecordsUploads do path: File.join('uploads', 'rails_sample.jpg'), size: 512.kilobytes, model: project, - uploader: uploader.class.to_s + uploader: uploader.class.to_s, + store: ::ObjectStorage::Store::LOCAL ) uploader.store!(upload_fixture('rails_sample.jpg')) diff --git a/spec/views/projects/pages/show.html.haml_spec.rb b/spec/views/projects/pages/show.html.haml_spec.rb index 80410e7bc32..39384484279 100644 --- a/spec/views/projects/pages/show.html.haml_spec.rb +++ b/spec/views/projects/pages/show.html.haml_spec.rb @@ -17,7 +17,7 @@ describe 'projects/pages/show' do assign(:project, project) allow(view).to receive(:current_user).and_return(user) - assign(:domains, [domain]) + assign(:domains, project.pages_domains) end describe 'validation warning' do diff --git a/yarn.lock b/yarn.lock index 42a5c0d29c3..81bbf8a59e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1036,10 +1036,10 @@ "@sentry/types" "5.10.0" tslib "^1.9.3" -"@sourcegraph/code-host-integration@0.0.34": - version "0.0.34" - resolved "https://registry.yarnpkg.com/@sourcegraph/code-host-integration/-/code-host-integration-0.0.34.tgz#c8f94854d64fe035926bbda7bed3a538a7259d03" - integrity sha512-TAa5kU/zPb9PfB4HIhaEDhKKdW5Fx9YVx9WWMOwz9elD0y9FZoAXDO1o4Pz1cm1IP/VZwd8csypAWgfxsAmfzw== +"@sourcegraph/code-host-integration@0.0.36": + version "0.0.36" + resolved "https://registry.yarnpkg.com/@sourcegraph/code-host-integration/-/code-host-integration-0.0.36.tgz#2f4d287840ac2944c78ef92f10f0db0ef8a077fa" + integrity sha512-Hpj1xiVhPxMsjLNre9MrYYAM1SPOWPE9yG9SPtz4dqYzc6/ycaPGyr+ljcaWEclS9hZCvkk4+qVC5WONpYVjyA== "@types/anymatch@*": version "1.3.0" |