diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-13 15:08:53 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-13 15:08:53 +0000 |
commit | 16515bdfcb89ccb28e6eb81020d1646dfa7c6fa4 (patch) | |
tree | ca7676376780d0c476b345f36827a61a4f6d84d3 | |
parent | 6e91fbf77476011a7fd86ca3467aad6d7b110ff3 (diff) | |
download | gitlab-ce-16515bdfcb89ccb28e6eb81020d1646dfa7c6fa4.tar.gz |
Add latest changes from gitlab-org/gitlab@master
174 files changed, 2306 insertions, 347 deletions
@@ -172,7 +172,7 @@ gem 'diffy', '~> 3.3' gem 'diff_match_patch', '~> 0.1.0' # Application server -gem 'rack', '~> 2.0.9' +gem 'rack', '~> 2.1.4' # https://github.com/sharpstone/rack-timeout/blob/master/README.md#rails-apps-manually gem 'rack-timeout', '~> 0.5.1', require: 'rack/timeout/base' diff --git a/Gemfile.lock b/Gemfile.lock index 8699ec0a21d..0a6b37ab9ee 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -852,7 +852,7 @@ GEM public_suffix (4.0.3) pyu-ruby-sasl (0.0.3.3) raabro (1.1.6) - rack (2.0.9) + rack (2.1.4) rack-accept (0.4.5) rack (>= 0.4) rack-attack (6.3.0) @@ -1423,7 +1423,7 @@ DEPENDENCIES prometheus-client-mmap (~> 0.12.0) pry-byebug (~> 3.9.0) pry-rails (~> 0.3.9) - rack (~> 2.0.9) + rack (~> 2.1.4) rack-attack (~> 6.3.0) rack-cors (~> 1.0.6) rack-oauth2 (~> 1.9.3) diff --git a/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue new file mode 100644 index 00000000000..020a0d43096 --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue @@ -0,0 +1,121 @@ +<script> +import { GlFormInput, GlFormSelect } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { PERCENT_ROLLOUT_GROUP_ID } from '../../constants'; +import ParameterFormGroup from './parameter_form_group.vue'; + +export default { + components: { + GlFormInput, + GlFormSelect, + ParameterFormGroup, + }, + props: { + strategy: { + required: true, + type: Object, + }, + }, + i18n: { + percentageDescription: __('Enter a whole number between 0 and 100'), + percentageInvalid: __('Percent rollout must be a whole number between 0 and 100'), + percentageLabel: __('Percentage'), + stickinessDescription: __('Consistency guarantee method'), + stickinessLabel: __('Based on'), + }, + stickinessOptions: [ + { + value: 'DEFAULT', + text: __('Available ID'), + }, + { + value: 'USERID', + text: __('User ID'), + }, + { + value: 'SESSIONID', + text: __('Session ID'), + }, + { + value: 'RANDOM', + text: __('Random'), + }, + ], + computed: { + isValid() { + const percentageNum = Number(this.percentage); + return Number.isInteger(percentageNum) && percentageNum >= 0 && percentageNum <= 100; + }, + percentage() { + return this.strategy?.parameters?.rollout ?? '100'; + }, + stickiness() { + return this.strategy?.parameters?.stickiness ?? this.$options.stickinessOptions[0].value; + }, + }, + methods: { + onPercentageChange(value) { + this.$emit('change', { + parameters: { + groupId: PERCENT_ROLLOUT_GROUP_ID, + rollout: value, + stickiness: this.stickiness, + }, + }); + }, + onStickinessChange(value) { + this.$emit('change', { + parameters: { + groupId: PERCENT_ROLLOUT_GROUP_ID, + rollout: this.percentage, + stickiness: value, + }, + }); + }, + }, +}; +</script> +<template> + <div class="gl-display-flex"> + <div class="gl-mr-7" data-testid="strategy-flexible-rollout-percentage"> + <parameter-form-group + :label="$options.i18n.percentageLabel" + :description="isValid ? $options.i18n.percentageDescription : ''" + :invalid-feedback="$options.i18n.percentageInvalid" + :state="isValid" + > + <template #default="{ inputId }"> + <div class="gl-display-flex gl-align-items-center"> + <gl-form-input + :id="inputId" + :value="percentage" + :state="isValid" + class="rollout-percentage gl-text-right gl-w-9" + type="number" + min="0" + max="100" + @input="onPercentageChange" + /> + <span class="ml-1">%</span> + </div> + </template> + </parameter-form-group> + </div> + + <div class="gl-mr-7" data-testid="strategy-flexible-rollout-stickiness"> + <parameter-form-group + :label="$options.i18n.stickinessLabel" + :description="$options.i18n.stickinessDescription" + > + <template #default="{ inputId }"> + <gl-form-select + :id="inputId" + :value="stickiness" + :options="$options.stickinessOptions" + @change="onStickinessChange" + /> + </template> + </parameter-form-group> + </div> + </div> +</template> diff --git a/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue b/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue index b13bd86e900..ec97e8b1350 100644 --- a/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue +++ b/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue @@ -49,7 +49,7 @@ export default { :state="hasUserLists" :invalid-feedback="$options.translations.rolloutUserListNoListError" :label="$options.translations.rolloutUserListLabel" - :description="$options.translations.rolloutUserListDescription" + :description="hasUserLists ? $options.translations.rolloutUserListDescription : ''" > <template #default="{ inputId }"> <gl-form-select diff --git a/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue b/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue index 9311589c364..d262769c891 100644 --- a/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue +++ b/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue @@ -15,7 +15,7 @@ export default { type: Object, }, }, - translations: { + i18n: { rolloutPercentageDescription: __('Enter a whole number between 0 and 100'), rolloutPercentageInvalid: s__( 'FeatureFlags|Percent rollout must be a whole number between 0 and 100', @@ -24,10 +24,11 @@ export default { }, computed: { isValid() { - return Number(this.percentage) >= 0 && Number(this.percentage) <= 100; + const percentageNum = Number(this.percentage); + return Number.isInteger(percentageNum) && percentageNum >= 0 && percentageNum <= 100; }, percentage() { - return this.strategy?.parameters?.percentage ?? ''; + return this.strategy?.parameters?.percentage ?? '100'; }, }, methods: { @@ -44,9 +45,9 @@ export default { </script> <template> <parameter-form-group - :label="$options.translations.rolloutPercentageLabel" - :description="$options.translations.rolloutPercentageDescription" - :invalid-feedback="$options.translations.rolloutPercentageInvalid" + :label="$options.i18n.rolloutPercentageLabel" + :description="isValid ? $options.i18n.rolloutPercentageDescription : ''" + :invalid-feedback="$options.i18n.rolloutPercentageInvalid" :state="isValid" > <template #default="{ inputId }"> diff --git a/app/assets/javascripts/feature_flags/components/strategy.vue b/app/assets/javascripts/feature_flags/components/strategy.vue index c83e2c897e3..ae559a4c9e3 100644 --- a/app/assets/javascripts/feature_flags/components/strategy.vue +++ b/app/assets/javascripts/feature_flags/components/strategy.vue @@ -1,15 +1,20 @@ <script> import Vue from 'vue'; import { isNumber } from 'lodash'; -import { GlButton, GlFormSelect, GlFormGroup, GlIcon, GlLink, GlToken } from '@gitlab/ui'; +import { GlAlert, GlButton, GlFormSelect, GlFormGroup, GlIcon, GlLink, GlToken } from '@gitlab/ui'; import { s__, __ } from '~/locale'; -import { EMPTY_PARAMETERS, STRATEGY_SELECTIONS } from '../constants'; +import { + EMPTY_PARAMETERS, + STRATEGY_SELECTIONS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, +} from '../constants'; import NewEnvironmentsDropdown from './new_environments_dropdown.vue'; import StrategyParameters from './strategy_parameters.vue'; export default { components: { + GlAlert, GlButton, GlFormGroup, GlFormSelect, @@ -51,13 +56,13 @@ export default { i18n: { allEnvironments: __('All environments'), environmentsLabel: __('Environments'), - rolloutUserListLabel: s__('FeatureFlag|List'), - rolloutUserListDescription: s__('FeatureFlag|Select a user list'), - rolloutUserListNoListError: s__('FeatureFlag|There are no configured user lists'), - strategyTypeDescription: __('Select strategy activation method.'), + strategyTypeDescription: __('Select strategy activation method'), strategyTypeLabel: s__('FeatureFlag|Type'), environmentsSelectDescription: s__( - 'FeatureFlag|Select the environment scope for this feature flag.', + 'FeatureFlag|Select the environment scope for this feature flag', + ), + considerFlexibleRollout: s__( + 'FeatureFlags|Consider using the more flexible "Percent rollout" strategy instead.', ), }, @@ -85,6 +90,9 @@ export default { filteredEnvironments() { return this.environments.filter(e => !e.shouldBeDestroyed); }, + isPercentUserRollout() { + return this.formStrategy.name === ROLLOUT_STRATEGY_PERCENT_ROLLOUT; + }, }, methods: { addEnvironment(environment) { @@ -121,73 +129,84 @@ export default { }; </script> <template> - <div class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-100 gl-py-6"> - <div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row flex-md-wrap"> - <div class="mr-5"> - <gl-form-group :label="$options.i18n.strategyTypeLabel" :label-for="strategyTypeId"> - <p class="gl-display-inline-block ">{{ $options.i18n.strategyTypeDescription }}</p> - <gl-link :href="strategyTypeDocsPagePath" target="_blank"> - <gl-icon name="question" /> - </gl-link> - <gl-form-select - :id="strategyTypeId" - :value="formStrategy.name" - :options="$options.strategies" - @change="onStrategyTypeChange" + <div> + <gl-alert v-if="isPercentUserRollout" variant="tip" :dismissible="false"> + {{ $options.i18n.considerFlexibleRollout }} + </gl-alert> + + <div class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-100 gl-py-6"> + <div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row flex-md-wrap"> + <div class="mr-5"> + <gl-form-group :label="$options.i18n.strategyTypeLabel" :label-for="strategyTypeId"> + <template #description> + {{ $options.i18n.strategyTypeDescription }} + <gl-link :href="strategyTypeDocsPagePath" target="_blank"> + <gl-icon name="question" /> + </gl-link> + </template> + <gl-form-select + :id="strategyTypeId" + :value="formStrategy.name" + :options="$options.strategies" + @change="onStrategyTypeChange" + /> + </gl-form-group> + </div> + + <div data-testid="strategy"> + <strategy-parameters + :strategy="strategy" + :user-lists="userLists" + @change="onStrategyChange" /> - </gl-form-group> - </div> + </div> - <div data-testid="strategy"> - <strategy-parameters - :strategy="strategy" - :user-lists="userLists" - @change="onStrategyChange" - /> + <div + class="align-self-end align-self-md-stretch order-first offset-md-0 order-md-0 gl-ml-auto" + > + <gl-button + data-testid="delete-strategy-button" + variant="danger" + icon="remove" + @click="$emit('delete')" + /> + </div> </div> - <div - class="align-self-end align-self-md-stretch order-first offset-md-0 order-md-0 gl-ml-auto" - > - <gl-button - data-testid="delete-strategy-button" - variant="danger" - icon="remove" - @click="$emit('delete')" - /> - </div> - </div> - <label class="gl-display-block" :for="environmentsDropdownId">{{ - $options.i18n.environmentsLabel - }}</label> - <p class="gl-display-inline-block">{{ $options.i18n.environmentsSelectDescription }}</p> - <gl-link :href="environmentsScopeDocsPath" target="_blank"> - <gl-icon name="question" /> - </gl-link> - <div class="gl-display-flex gl-flex-direction-column"> - <div - class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row align-items-start gl-md-align-items-center" - > - <new-environments-dropdown - :id="environmentsDropdownId" - :endpoint="endpoint" - class="gl-mr-3" - @add="addEnvironment" - /> - <span v-if="appliesToAllEnvironments" class="text-secondary gl-mt-3 mt-md-0 ml-md-3"> - {{ $options.i18n.allEnvironments }} - </span> - <div v-else class="gl-display-flex gl-align-items-center"> - <gl-token - v-for="environment in filteredEnvironments" - :key="environment.id" - class="gl-mt-3 gl-mr-3 mt-md-0 mr-md-0 ml-md-2 rounded-pill" - @close="removeScope(environment)" - > - {{ environment.environmentScope }} - </gl-token> + <label class="gl-display-block" :for="environmentsDropdownId">{{ + $options.i18n.environmentsLabel + }}</label> + <div class="gl-display-flex gl-flex-direction-column"> + <div + class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row align-items-start gl-md-align-items-center" + > + <new-environments-dropdown + :id="environmentsDropdownId" + :endpoint="endpoint" + class="gl-mr-3" + @add="addEnvironment" + /> + <span v-if="appliesToAllEnvironments" class="text-secondary gl-mt-3 mt-md-0 ml-md-3"> + {{ $options.i18n.allEnvironments }} + </span> + <div v-else class="gl-display-flex gl-align-items-center"> + <gl-token + v-for="environment in filteredEnvironments" + :key="environment.id" + class="gl-mt-3 gl-mr-3 mt-md-0 mr-md-0 ml-md-2 rounded-pill" + @close="removeScope(environment)" + > + {{ environment.environmentScope }} + </gl-token> + </div> </div> </div> + <span class="gl-display-inline-block gl-py-3"> + {{ $options.i18n.environmentsSelectDescription }} + </span> + <gl-link :href="environmentsScopeDocsPath" target="_blank"> + <gl-icon name="question" /> + </gl-link> </div> </div> </template> diff --git a/app/assets/javascripts/feature_flags/components/strategy_parameters.vue b/app/assets/javascripts/feature_flags/components/strategy_parameters.vue index 6953095daff..b6e06880315 100644 --- a/app/assets/javascripts/feature_flags/components/strategy_parameters.vue +++ b/app/assets/javascripts/feature_flags/components/strategy_parameters.vue @@ -1,18 +1,21 @@ <script> import { ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, ROLLOUT_STRATEGY_PERCENT_ROLLOUT, ROLLOUT_STRATEGY_USER_ID, ROLLOUT_STRATEGY_GITLAB_USER_LIST, } from '../constants'; import Default from './strategies/default.vue'; +import FlexibleRollout from './strategies/flexible_rollout.vue'; import PercentRollout from './strategies/percent_rollout.vue'; import UsersWithId from './strategies/users_with_id.vue'; import GitlabUserList from './strategies/gitlab_user_list.vue'; const STRATEGIES = Object.freeze({ [ROLLOUT_STRATEGY_ALL_USERS]: Default, + [ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT]: FlexibleRollout, [ROLLOUT_STRATEGY_PERCENT_ROLLOUT]: PercentRollout, [ROLLOUT_STRATEGY_USER_ID]: UsersWithId, [ROLLOUT_STRATEGY_GITLAB_USER_LIST]: GitlabUserList, diff --git a/app/assets/javascripts/feature_flags/constants.js b/app/assets/javascripts/feature_flags/constants.js index 79bd6d8fe43..4843eca149a 100644 --- a/app/assets/javascripts/feature_flags/constants.js +++ b/app/assets/javascripts/feature_flags/constants.js @@ -3,6 +3,7 @@ import { s__ } from '~/locale'; export const ROLLOUT_STRATEGY_ALL_USERS = 'default'; export const ROLLOUT_STRATEGY_PERCENT_ROLLOUT = 'gradualRolloutUserId'; +export const ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT = 'flexibleRollout'; export const ROLLOUT_STRATEGY_USER_ID = 'userWithId'; export const ROLLOUT_STRATEGY_GITLAB_USER_LIST = 'gitlabUserList'; @@ -35,6 +36,10 @@ export const STRATEGY_SELECTIONS = [ text: s__('FeatureFlags|All users'), }, { + value: ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, + text: s__('FeatureFlags|Percent rollout'), + }, + { value: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, text: s__('FeatureFlags|Percent of users'), }, diff --git a/app/assets/javascripts/feature_flags/utils.js b/app/assets/javascripts/feature_flags/utils.js index ccb6ac17792..24c570657e6 100644 --- a/app/assets/javascripts/feature_flags/utils.js +++ b/app/assets/javascripts/feature_flags/utils.js @@ -2,6 +2,7 @@ import { s__, n__, sprintf } from '~/locale'; import { ALL_ENVIRONMENTS_NAME, ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, ROLLOUT_STRATEGY_PERCENT_ROLLOUT, ROLLOUT_STRATEGY_USER_ID, ROLLOUT_STRATEGY_GITLAB_USER_LIST, @@ -12,6 +13,23 @@ const badgeTextByType = { name: s__('FeatureFlags|All Users'), parameters: null, }, + [ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT]: { + name: s__('FeatureFlags|Percent rollout'), + parameters: ({ parameters: { rollout, stickiness } }) => { + switch (stickiness) { + case 'USERID': + return sprintf(s__('FeatureFlags|%{percent} by user ID'), { percent: `${rollout}%` }); + case 'SESSIONID': + return sprintf(s__('FeatureFlags|%{percent} by session ID'), { percent: `${rollout}%` }); + case 'RANDOM': + return sprintf(s__('FeatureFlags|%{percent} randomly'), { percent: `${rollout}%` }); + default: + return sprintf(s__('FeatureFlags|%{percent} by available ID'), { + percent: `${rollout}%`, + }); + } + }, + }, [ROLLOUT_STRATEGY_PERCENT_ROLLOUT]: { name: s__('FeatureFlags|Percent of users'), parameters: ({ parameters: { percentage } }) => `${percentage}%`, diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue index dfce1cb75d3..0f145dbc170 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue @@ -1,17 +1,17 @@ <script> +import { GlIcon } from '@gitlab/ui'; import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue'; import { featureAccessLevelNone } from '../constants'; export default { components: { + GlIcon, projectFeatureToggle, }, - model: { prop: 'value', event: 'change', }, - props: { name: { type: String, @@ -34,7 +34,6 @@ export default { default: false, }, }, - computed: { featureEnabled() { return this.value !== 0; @@ -51,7 +50,6 @@ export default { return this.disabledInput || !this.featureEnabled || this.displayOptions.length < 2; }, }, - methods: { toggleFeature(featureEnabled) { if (featureEnabled === false || this.options.length < 1) { @@ -96,7 +94,11 @@ export default { {{ optionName }} </option> </select> - <i aria-hidden="true" class="fa fa-chevron-down"> </i> + <gl-icon + name="chevron-down" + aria-hidden="true" + class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" + /> </div> </div> </template> diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index e19afe67789..bcf82e264d1 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -1,5 +1,5 @@ <script> -import { GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui'; +import { GlIcon, GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui'; import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin'; import { s__ } from '~/locale'; @@ -22,6 +22,7 @@ export default { projectFeatureSetting, projectFeatureToggle, projectSettingRow, + GlIcon, GlSprintf, GlLink, GlFormCheckbox, @@ -325,7 +326,12 @@ export default { >{{ s__('ProjectSettings|Public') }}</option > </select> - <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i> + <gl-icon + name="chevron-down" + aria-hidden="true" + data-hidden="true" + class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" + /> </div> </div> <span class="form-text text-muted">{{ visibilityLevelDescription }}</span> @@ -540,7 +546,12 @@ export default { >{{ featureAccessLevelEveryone[1] }}</option > </select> - <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i> + <gl-icon + name="chevron-down" + aria-hidden="true" + data-hidden="true" + class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" + /> </div> </div> </project-setting-row> diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue index 0851ee21289..62b7e02c52a 100644 --- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue +++ b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue @@ -1,7 +1,6 @@ <script> import $ from 'jquery'; import { difference, union } from 'lodash'; -import { mapState, mapActions } from 'vuex'; import flash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -26,47 +25,49 @@ export default { 'projectIssuesPath', 'projectPath', ], - data: () => ({ - labelsSelectInProgress: false, - }), - computed: { - ...mapState(['selectedLabels']), - }, - mounted() { - this.setInitialState({ + data() { + return { + isLabelsSelectInProgress: false, selectedLabels: this.initiallySelectedLabels, - }); + }; }, methods: { - ...mapActions(['setInitialState', 'replaceSelectedLabels']), handleDropdownClose() { $(this.$el).trigger('hidden.gl.dropdown'); }, - handleUpdateSelectedLabels(labels) { + handleUpdateSelectedLabels(dropdownLabels) { const currentLabelIds = this.selectedLabels.map(label => label.id); - const userAddedLabelIds = labels.filter(label => label.set).map(label => label.id); - const userRemovedLabelIds = labels.filter(label => !label.set).map(label => label.id); + const userAddedLabelIds = dropdownLabels.filter(label => label.set).map(label => label.id); + const userRemovedLabelIds = dropdownLabels.filter(label => !label.set).map(label => label.id); - const issuableLabels = difference( - union(currentLabelIds, userAddedLabelIds), - userRemovedLabelIds, - ); + const labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds); - this.labelsSelectInProgress = true; + this.updateSelectedLabels(labelIds); + }, + handleLabelRemove(labelId) { + const currentLabelIds = this.selectedLabels.map(label => label.id); + const labelIds = difference(currentLabelIds, [labelId]); + + this.updateSelectedLabels(labelIds); + }, + updateSelectedLabels(labelIds) { + this.isLabelsSelectInProgress = true; axios({ data: { [this.issuableType]: { - label_ids: issuableLabels, + label_ids: labelIds, }, }, method: 'put', url: this.labelsUpdatePath, }) - .then(({ data }) => this.replaceSelectedLabels(data.labels)) + .then(({ data }) => { + this.selectedLabels = data.labels; + }) .catch(() => flash(__('An error occurred while updating labels.'))) .finally(() => { - this.labelsSelectInProgress = false; + this.isLabelsSelectInProgress = false; }); }, }, @@ -76,6 +77,7 @@ export default { <template> <labels-select class="block labels js-labels-block" + :allow-label-remove="true" :allow-label-create="allowLabelCreate" :allow-label-edit="allowLabelEdit" :allow-multiselect="true" @@ -86,11 +88,12 @@ export default { :labels-fetch-path="labelsFetchPath" :labels-filter-base-path="projectIssuesPath" :labels-manage-path="labelsManagePath" - :labels-select-in-progress="labelsSelectInProgress" + :labels-select-in-progress="isLabelsSelectInProgress" :selected-labels="selectedLabels" :variant="$options.sidebar" data-qa-selector="labels_block" @onDropdownClose="handleDropdownClose" + @onLabelRemove="handleLabelRemove" @updateSelectedLabels="handleUpdateSelectedLabels" > {{ __('None') }} diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index a25a7b0b2fe..00b4e2de5e5 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -1,7 +1,6 @@ import $ from 'jquery'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import Vuex from 'vuex'; import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import SidebarLabels from './components/labels/sidebar_labels.vue'; @@ -17,11 +16,9 @@ import createDefaultClient from '~/lib/graphql'; import { isInIssuePage, isInIncidentPage, parseBoolean } from '~/lib/utils/common_utils'; import createFlash from '~/flash'; import { __ } from '~/locale'; -import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; Vue.use(Translate); Vue.use(VueApollo); -Vue.use(Vuex); function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-options')) { return JSON.parse(sidebarOptEl.innerHTML); @@ -94,8 +91,6 @@ export function mountSidebarLabels() { return false; } - const labelsStore = new Vuex.Store(labelsSelectModule()); - return new Vue({ el, provide: { @@ -105,7 +100,6 @@ export function mountSidebarLabels() { allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels), initiallySelectedLabels: JSON.parse(el.dataset.selectedLabels), }, - store: labelsStore, render: createElement => createElement(SidebarLabels), }); } diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue b/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue index 4cd74305450..e5e7cdf149c 100644 --- a/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue +++ b/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue @@ -8,11 +8,13 @@ import { import { generateBadges } from 'ee_else_ce/vue_shared/components/members/utils'; import { __ } from '~/locale'; import { AVATAR_SIZE } from '../constants'; +import { glEmojiTag } from '~/emoji'; export default { name: 'UserAvatar', avatarSize: AVATAR_SIZE, orphanedUserLabel: __('Orphaned member'), + safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, components: { GlAvatarLink, GlAvatarLabeled, @@ -38,6 +40,12 @@ export default { badges() { return generateBadges(this.member, this.isCurrentUser).filter(badge => badge.show); }, + statusEmoji() { + return this.user?.status?.emoji; + }, + }, + methods: { + glEmojiTag, }, }; </script> @@ -60,6 +68,9 @@ export default { :entity-id="user.id" > <template #meta> + <div v-if="statusEmoji" class="gl-p-1"> + <span v-safe-html:[$options.safeHtmlConfig]="glEmojiTag(statusEmoji)"></span> + </div> <div v-for="badge in badges" :key="badge.text" class="gl-p-1"> <gl-badge size="sm" :variant="badge.variant"> {{ badge.text }} diff --git a/app/assets/javascripts/vue_shared/components/members/constants.js b/app/assets/javascripts/vue_shared/components/members/constants.js index 8b504d06bb2..665ba1df547 100644 --- a/app/assets/javascripts/vue_shared/components/members/constants.js +++ b/app/assets/javascripts/vue_shared/components/members/constants.js @@ -38,8 +38,8 @@ export const FIELDS = [ { key: 'maxRole', label: __('Max role'), - thClass: 'col-meta', - tdClass: 'col-meta', + thClass: 'col-max-role', + tdClass: 'col-max-role', }, { key: 'expiration', diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue b/app/assets/javascripts/vue_shared/components/members/table/members_table.vue index d3c77c1d3ee..4580e4a9f19 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue +++ b/app/assets/javascripts/vue_shared/components/members/table/members_table.vue @@ -1,6 +1,6 @@ <script> import { mapState } from 'vuex'; -import { GlTable } from '@gitlab/ui'; +import { GlTable, GlBadge } from '@gitlab/ui'; import { FIELDS } from '../constants'; import initUserPopovers from '~/user_popovers'; import MemberAvatar from './member_avatar.vue'; @@ -9,17 +9,20 @@ import CreatedAt from './created_at.vue'; import ExpiresAt from './expires_at.vue'; import MemberActionButtons from './member_action_buttons.vue'; import MembersTableCell from './members_table_cell.vue'; +import RoleDropdown from './role_dropdown.vue'; export default { name: 'MembersTable', components: { GlTable, + GlBadge, MemberAvatar, CreatedAt, ExpiresAt, MembersTableCell, MemberSource, MemberActionButtons, + RoleDropdown, }, computed: { ...mapState(['members', 'tableFields']), @@ -77,6 +80,13 @@ export default { <expires-at :date="expiresAt" /> </template> + <template #cell(maxRole)="{ item: member }"> + <members-table-cell #default="{ permissions }" :member="member"> + <role-dropdown v-if="permissions.canUpdate" :member="member" /> + <gl-badge v-else>{{ member.accessLevel.stringValue }}</gl-badge> + </members-table-cell> + </template> + <template #cell(actions)="{ item: member }"> <members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member"> <member-action-buttons diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue b/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue index 1ffba579f40..5602978bb6c 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue +++ b/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue @@ -33,7 +33,7 @@ export default { return MEMBER_TYPES.user; }, isDirectMember() { - return this.member.source?.id === this.sourceId; + return this.isGroup || this.member.source?.id === this.sourceId; }, isCurrentUser() { return this.member.user?.id === this.currentUserId; @@ -44,6 +44,9 @@ export default { canResend() { return Boolean(this.member.invite?.canResend); }, + canUpdate() { + return !this.isCurrentUser && this.isDirectMember && this.member.canUpdate; + }, }, render() { return this.$scopedSlots.default({ @@ -53,6 +56,7 @@ export default { permissions: { canRemove: this.canRemove, canResend: this.canResend, + canUpdate: this.canUpdate, }, }); }, diff --git a/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue b/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue new file mode 100644 index 00000000000..604dc942be2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue @@ -0,0 +1,49 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; + +export default { + name: 'RoleDropdown', + components: { + GlDropdown, + GlDropdownItem, + }, + props: { + member: { + type: Object, + required: true, + }, + }, + data() { + return { + isDesktop: false, + }; + }, + mounted() { + this.isDesktop = bp.isDesktop(); + }, + methods: { + handleSelect() { + // Vuex action will be called here to make API request and update `member.accessLevel` + }, + }, +}; +</script> + +<template> + <gl-dropdown + :right="!isDesktop" + :text="member.accessLevel.stringValue" + :header-text="__('Change permissions')" + > + <gl-dropdown-item + v-for="(value, name) in member.validRoles" + :key="value" + is-check-item + :is-checked="value === member.accessLevel.integerValue" + @click="handleSelect" + > + {{ name }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue index 286067a0d0f..a6f99289df4 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue @@ -8,8 +8,20 @@ export default { components: { GlLabel, }, + props: { + disableLabels: { + type: Boolean, + required: false, + default: false, + }, + }, computed: { - ...mapState(['selectedLabels', 'allowScopedLabels', 'labelsFilterBasePath']), + ...mapState([ + 'selectedLabels', + 'allowLabelRemove', + 'allowScopedLabels', + 'labelsFilterBasePath', + ]), }, methods: { labelFilterUrl(label) { @@ -42,7 +54,10 @@ export default { :background-color="label.color" :target="labelFilterUrl(label)" :scoped="scopedLabel(label)" + :show-close-button="allowLabelRemove" + :disabled="disableLabels" tooltip-placement="top" + @close="$emit('onLabelRemove', label.id)" /> </template> </div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue index 34f5517ef99..c651013c5f5 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue @@ -28,6 +28,11 @@ export default { DropdownValueCollapsed, }, props: { + allowLabelRemove: { + type: Boolean, + required: false, + default: false, + }, allowLabelEdit: { type: Boolean, required: true, @@ -130,6 +135,7 @@ export default { mounted() { this.setInitialState({ variant: this.variant, + allowLabelRemove: this.allowLabelRemove, allowLabelEdit: this.allowLabelEdit, allowLabelCreate: this.allowLabelCreate, allowMultiselect: this.allowMultiselect, @@ -252,7 +258,10 @@ export default { :allow-label-edit="allowLabelEdit" :labels-select-in-progress="labelsSelectInProgress" /> - <dropdown-value> + <dropdown-value + :disable-labels="labelsSelectInProgress" + @onLabelRemove="$emit('onLabelRemove', $event)" + > <slot></slot> </dropdown-value> <dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" /> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js index 2d236566b3d..e624bd1eaee 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js @@ -54,8 +54,5 @@ export const createLabel = ({ state, dispatch }, label) => { }); }; -export const replaceSelectedLabels = ({ commit }, selectedLabels) => - commit(types.REPLACE_SELECTED_LABELS, selectedLabels); - export const updateSelectedLabels = ({ commit }, labels) => commit(types.UPDATE_SELECTED_LABELS, { labels }); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js index af92665d4eb..2e044dc3b3c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js @@ -15,7 +15,6 @@ export const RECEIVE_CREATE_LABEL_FAILURE = 'RECEIVE_CREATE_LABEL_FAILURE'; export const TOGGLE_DROPDOWN_BUTTON = 'TOGGLE_DROPDOWN_VISIBILITY'; export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS'; -export const REPLACE_SELECTED_LABELS = 'REPLACE_SELECTED_LABELS'; export const UPDATE_SELECTED_LABELS = 'UPDATE_SELECTED_LABELS'; export const TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW = 'TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW'; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js index 7edd290a819..54f8c78b4e1 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js @@ -57,10 +57,6 @@ export default { state.labelCreateInProgress = false; }, - [types.REPLACE_SELECTED_LABELS](state, selectedLabels = []) { - state.selectedLabels = selectedLabels; - }, - [types.UPDATE_SELECTED_LABELS](state, { labels }) { // Find the label to update from all the labels // and change `set` prop value to represent their current state. diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js index 3f3358d4805..d66cfed4163 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js @@ -15,6 +15,7 @@ export default () => ({ // UI Flags variant: '', + allowLabelRemove: false, allowLabelCreate: false, allowLabelEdit: false, allowScopedLabels: false, diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index 11d5104f64d..922f95ff5df 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -216,6 +216,10 @@ width: px-to-rem(150px); } + .col-max-role { + width: px-to-rem(175px); + } + .col-expiration { width: px-to-rem(200px); } diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index ea0a0b62735..9afad86185d 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -171,7 +171,7 @@ class Admin::UsersController < Admin::ApplicationController # restore username to keep form action url. user.username = params[:id] format.html { render "edit" } - format.json { render json: [result[:message]], status: result[:status] } + format.json { render json: [result[:message]], status: :internal_server_error } end end end diff --git a/app/controllers/projects/merge_requests/drafts_controller.rb b/app/controllers/projects/merge_requests/drafts_controller.rb index f4846b1aa81..ca3f36cafe1 100644 --- a/app/controllers/projects/merge_requests/drafts_controller.rb +++ b/app/controllers/projects/merge_requests/drafts_controller.rb @@ -45,7 +45,7 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli if result[:status] == :success head :ok else - render json: { message: result[:message] }, status: result[:status] + render json: { message: result[:message] }, status: :internal_server_error end end diff --git a/app/graphql/types/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb index d358e3fcc0d..4086ca46a60 100644 --- a/app/graphql/types/ci/detailed_status_type.rb +++ b/app/graphql/types/ci/detailed_status_type.rb @@ -7,22 +7,22 @@ module Types graphql_name 'DetailedStatus' field :group, GraphQL::STRING_TYPE, null: false, - description: 'Group of the pipeline status' + description: 'Group of the status' field :icon, GraphQL::STRING_TYPE, null: false, - description: 'Icon of the pipeline status' + description: 'Icon of the status' field :favicon, GraphQL::STRING_TYPE, null: false, - description: 'Favicon of the pipeline status' - field :details_path, GraphQL::STRING_TYPE, null: false, - description: 'Path of the details for the pipeline status' + description: 'Favicon of the status' + field :details_path, GraphQL::STRING_TYPE, null: true, + description: 'Path of the details for the status' field :has_details, GraphQL::BOOLEAN_TYPE, null: false, - description: 'Indicates if the pipeline status has further details', + description: 'Indicates if the status has further details', method: :has_details? field :label, GraphQL::STRING_TYPE, null: false, - description: 'Label of the pipeline status' + description: 'Label of the status' field :text, GraphQL::STRING_TYPE, null: false, - description: 'Text of the pipeline status' + description: 'Text of the status' field :tooltip, GraphQL::STRING_TYPE, null: false, - description: 'Tooltip associated with the pipeline status', + description: 'Tooltip associated with the status', method: :status_tooltip field :action, Types::Ci::StatusActionType, null: true, description: 'Action information for the status. This includes method, button title, icon, path, and title', diff --git a/app/graphql/types/ci/group_type.rb b/app/graphql/types/ci/group_type.rb index 04c0eb93068..d930ae311b7 100644 --- a/app/graphql/types/ci/group_type.rb +++ b/app/graphql/types/ci/group_type.rb @@ -12,6 +12,9 @@ module Types description: 'Size of the group' field :jobs, Ci::JobType.connection_type, null: true, description: 'Jobs in group' + field :detailed_status, Types::Ci::DetailedStatusType, null: true, + description: 'Detailed status of the group', + resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) } end end end diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb index 4c18f3ffd52..bed0e74a920 100644 --- a/app/graphql/types/ci/job_type.rb +++ b/app/graphql/types/ci/job_type.rb @@ -10,6 +10,9 @@ module Types description: 'Name of the job' field :needs, JobType.connection_type, null: true, description: 'Builds that must complete before the jobs run' + field :detailed_status, Types::Ci::DetailedStatusType, null: true, + description: 'Detailed status of the job', + resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) } end end end diff --git a/app/graphql/types/ci/stage_type.rb b/app/graphql/types/ci/stage_type.rb index 278c4d4d748..fc2c72d0d06 100644 --- a/app/graphql/types/ci/stage_type.rb +++ b/app/graphql/types/ci/stage_type.rb @@ -10,6 +10,9 @@ module Types description: 'Name of the stage' field :groups, Ci::GroupType.connection_type, null: true, description: 'Group of jobs for the stage' + field :detailed_status, Types::Ci::DetailedStatusType, null: true, + description: 'Detailed status of the stage', + resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) } end end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 060c155401f..aef08c433ab 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -471,7 +471,8 @@ module ProjectsHelper labels: :read_label, issues: :read_issue, project_members: :read_project_member, - wiki: :read_wiki + wiki: :read_wiki, + feature_flags: :read_feature_flag } end @@ -482,7 +483,8 @@ module ProjectsHelper :read_environment, :read_issue, :read_sentry_issue, - :read_cluster + :read_cluster, + :read_feature_flag ].any? do |ability| can?(current_user, ability, project) end @@ -561,7 +563,11 @@ module ProjectsHelper end def sidebar_operations_link_path(project = @project) - metrics_project_environments_path(project) if can?(current_user, :read_environment, project) + if can?(current_user, :read_environment, project) + metrics_project_environments_path(project) + else + project_feature_flags_path(project) + end end def project_last_activity(project) @@ -754,6 +760,7 @@ module ProjectsHelper logs product_analytics metrics_dashboard + feature_flags tracings ] end diff --git a/app/models/ci/deleted_object.rb b/app/models/ci/deleted_object.rb new file mode 100644 index 00000000000..e74946eda16 --- /dev/null +++ b/app/models/ci/deleted_object.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Ci + class DeletedObject < ApplicationRecord + extend Gitlab::Ci::Model + + mount_uploader :file, DeletedObjectUploader + + scope :ready_for_destruction, ->(limit) do + where('pick_up_at < ?', Time.current).limit(limit) + end + + scope :lock_for_destruction, ->(limit) do + ready_for_destruction(limit) + .select(:id) + .order(:pick_up_at) + .lock('FOR UPDATE SKIP LOCKED') + end + + def self.bulk_import(artifacts) + attributes = artifacts.each.with_object([]) do |artifact, accumulator| + record = artifact.to_deleted_object_attrs + accumulator << record if record[:store_dir] && record[:file] + end + + self.insert_all(attributes) if attributes.any? + end + + def delete_file_from_storage + file.remove! + true + rescue => exception + Gitlab::ErrorTracking.track_exception(exception) + false + end + end +end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 42c0185c366..02e17afdab0 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -290,6 +290,15 @@ module Ci max_size&.megabytes.to_i end + def to_deleted_object_attrs + { + file_store: file_store, + store_dir: file.store_dir.to_s, + file: file_identifier, + pick_up_at: expire_at || Time.current + } + end + private def set_size diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index c9e05b45dbd..b29451315e8 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -841,6 +841,25 @@ module Ci end end + def build_with_artifacts_in_self_and_descendants(name) + builds_in_self_and_descendants + .ordered_by_pipeline # find job in hierarchical order + .with_downloadable_artifacts + .find_by_name(name) + end + + def builds_in_self_and_descendants + Ci::Build.latest.where(pipeline: self_and_descendants) + end + + # Without using `unscoped`, caller scope is also included into the query. + # Using `unscoped` here will be redundant after Rails 6.1 + def self_and_descendants + ::Gitlab::Ci::PipelineObjectHierarchy + .new(self.class.unscoped.where(id: id), options: { same_project: true }) + .base_and_descendants + end + def bridge_triggered? source_bridge.present? end diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index b0169d6290a..4498e08d754 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -48,6 +48,7 @@ class CommitStatus < ApplicationRecord scope :ordered_by_stage, -> { order(stage_idx: :asc) } scope :latest_ordered, -> { latest.ordered.includes(project: :namespace) } scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) } + scope :ordered_by_pipeline, -> { order(pipeline_id: :asc) } scope :before_stage, -> (index) { where('stage_idx < ?', index) } scope :for_stage, -> (index) { where(stage_idx: index) } scope :after_stage, -> (index) { where('stage_idx > ?', index) } diff --git a/app/models/group.rb b/app/models/group.rb index 1dec831606b..5a18441e0ad 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -76,6 +76,7 @@ class Group < Namespace validate :visibility_level_allowed_by_projects validate :visibility_level_allowed_by_sub_groups validate :visibility_level_allowed_by_parent + validate :two_factor_authentication_allowed validates :variables, variable_duplicates: true validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 } @@ -589,6 +590,16 @@ class Group < Namespace errors.add(:visibility_level, "#{visibility} is not allowed since there are sub-groups with higher visibility.") end + def two_factor_authentication_allowed + return unless has_parent? + return unless require_two_factor_authentication + + ancestor_settings = ancestors.find_by(parent_id: nil).namespace_settings + return if ancestor_settings.allow_mfa_for_subgroups + + errors.add(:require_two_factor_authentication, _('is forbidden by a top-level group')) + end + def members_from_self_and_ancestor_group_shares group_group_link_table = GroupGroupLink.arel_table group_member_table = GroupMember.arel_table diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb index 40c46fa6e3d..6f31208f28b 100644 --- a/app/models/namespace_setting.rb +++ b/app/models/namespace_setting.rb @@ -4,6 +4,7 @@ class NamespaceSetting < ApplicationRecord belongs_to :namespace, inverse_of: :namespace_settings validate :default_branch_name_content + validate :allow_mfa_for_group NAMESPACE_SETTINGS_PARAMS = [:default_branch_name].freeze @@ -16,6 +17,12 @@ class NamespaceSetting < ApplicationRecord errors.add(:default_branch_name, "can not be an empty string") end end + + def allow_mfa_for_group + if namespace&.subgroup? && allow_mfa_for_subgroups == false + errors.add(:allow_mfa_for_subgroups, _('is not allowed since the group is not top-level group.')) + end + end end NamespaceSetting.prepend_if_ee('EE::NamespaceSetting') diff --git a/app/models/project.rb b/app/models/project.rb index 9fa93d9b4e4..d7f5254a6e3 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -951,7 +951,7 @@ class Project < ApplicationRecord latest_pipeline = ci_pipelines.latest_successful_for_ref(ref) return unless latest_pipeline - latest_pipeline.builds.latest.with_downloadable_artifacts.find_by(name: job_name) + latest_pipeline.build_with_artifacts_in_self_and_descendants(job_name) end def latest_successful_build_for_sha(job_name, sha) @@ -960,7 +960,7 @@ class Project < ApplicationRecord latest_pipeline = ci_pipelines.latest_successful_for_sha(sha) return unless latest_pipeline - latest_pipeline.builds.latest.with_downloadable_artifacts.find_by(name: job_name) + latest_pipeline.build_with_artifacts_in_self_and_descendants(job_name) end def latest_successful_build_for_ref!(job_name, ref = default_branch) diff --git a/app/models/project_services/confluence_service.rb b/app/models/project_services/confluence_service.rb index dd44a0d1d56..6db446fc04c 100644 --- a/app/models/project_services/confluence_service.rb +++ b/app/models/project_services/confluence_service.rb @@ -27,7 +27,7 @@ class ConfluenceService < Service end def description - s_('ConfluenceService|Connect a Confluence Cloud Workspace to your GitLab project') + s_('ConfluenceService|Connect a Confluence Cloud Workspace to GitLab') end def detailed_description diff --git a/app/models/project_services/packagist_service.rb b/app/models/project_services/packagist_service.rb index 35dbedd1341..21f0a2b2463 100644 --- a/app/models/project_services/packagist_service.rb +++ b/app/models/project_services/packagist_service.rb @@ -16,7 +16,7 @@ class PackagistService < Service end def description - 'Update your project on Packagist, the main Composer repository' + s_('Integrations|Update your projects on Packagist, the main Composer repository') end def self.to_param diff --git a/app/services/ci/delete_objects_service.rb b/app/services/ci/delete_objects_service.rb new file mode 100644 index 00000000000..bac99abadc9 --- /dev/null +++ b/app/services/ci/delete_objects_service.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Ci + class DeleteObjectsService + TransactionInProgressError = Class.new(StandardError) + TRANSACTION_MESSAGE = "can't perform network calls inside a database transaction" + BATCH_SIZE = 100 + RETRY_IN = 10.minutes + + def execute + objects = load_next_batch + destroy_everything(objects) + end + + def remaining_batches_count(max_batch_count:) + Ci::DeletedObject + .ready_for_destruction(max_batch_count * BATCH_SIZE) + .size + .fdiv(BATCH_SIZE) + .ceil + end + + private + + # rubocop: disable CodeReuse/ActiveRecord + def load_next_batch + # `find_by_sql` performs a write in this case and we need to wrap it in + # a transaction to stick to the primary database. + Ci::DeletedObject.transaction do + Ci::DeletedObject.find_by_sql([ + next_batch_sql, new_pick_up_at: RETRY_IN.from_now + ]) + end + end + # rubocop: enable CodeReuse/ActiveRecord + + def next_batch_sql + <<~SQL.squish + UPDATE "ci_deleted_objects" + SET "pick_up_at" = :new_pick_up_at + WHERE "ci_deleted_objects"."id" IN (#{locked_object_ids_sql}) + RETURNING * + SQL + end + + def locked_object_ids_sql + Ci::DeletedObject.lock_for_destruction(BATCH_SIZE).to_sql + end + + def destroy_everything(objects) + raise TransactionInProgressError, TRANSACTION_MESSAGE if transaction_open? + return unless objects.any? + + deleted = objects.select(&:delete_file_from_storage) + Ci::DeletedObject.id_in(deleted.map(&:id)).delete_all + end + + def transaction_open? + Ci::DeletedObject.connection.transaction_open? + end + end +end diff --git a/app/uploaders/deleted_object_uploader.rb b/app/uploaders/deleted_object_uploader.rb new file mode 100644 index 00000000000..fc0f62b920c --- /dev/null +++ b/app/uploaders/deleted_object_uploader.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class DeletedObjectUploader < GitlabUploader + include ObjectStorage::Concern + + storage_options Gitlab.config.artifacts + + def store_dir + model.store_dir + end +end diff --git a/app/views/admin/abuse_reports/_abuse_report.html.haml b/app/views/admin/abuse_reports/_abuse_report.html.haml index 4f55fb196d3..ae0da214fb7 100644 --- a/app/views/admin/abuse_reports/_abuse_report.html.haml +++ b/app/views/admin/abuse_reports/_abuse_report.html.haml @@ -25,8 +25,8 @@ = link_to _('Remove user & report'), admin_abuse_report_path(abuse_report, remove_user: true), data: { confirm: _("USER %{user} WILL BE REMOVED! Are you sure?") % { user: user.name } }, remote: true, method: :delete, class: "gl-button btn btn-sm btn-block btn-danger js-remove-tr" - if user && !user.blocked? - = link_to _('Block user'), block_admin_user_path(user), data: {confirm: _('USER WILL BE BLOCKED! Are you sure?')}, method: :put, class: "btn btn-sm btn-block" + = link_to _('Block user'), block_admin_user_path(user), data: {confirm: _('USER WILL BE BLOCKED! Are you sure?')}, method: :put, class: "gl-button btn btn-sm btn-block" - else .btn.btn-sm.disabled.btn-block = _('Already blocked') - = link_to _('Remove report'), [:admin, abuse_report], remote: true, method: :delete, class: "btn btn-sm btn-block btn-close js-remove-tr" + = link_to _('Remove report'), [:admin, abuse_report], remote: true, method: :delete, class: "gl-button btn btn-sm btn-block btn-close js-remove-tr" diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml index 0d01f1c57e0..0c3a4e73e30 100644 --- a/app/views/admin/applications/_form.html.haml +++ b/app/views/admin/applications/_form.html.haml @@ -40,5 +40,5 @@ = render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes .form-actions - = f.submit 'Submit', class: "btn btn-success wide" - = link_to "Cancel", admin_applications_path, class: "btn btn-cancel" + = f.submit 'Submit', class: "gl-button btn btn-success wide" + = link_to "Cancel", admin_applications_path, class: "gl-button btn btn-cancel" diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml index 0119cabf1ad..c1c1c2a4cfe 100644 --- a/app/views/admin/applications/index.html.haml +++ b/app/views/admin/applications/index.html.haml @@ -4,7 +4,7 @@ %p.light System OAuth applications don't belong to any user and can only be managed by admins %hr -%p= link_to 'New application', new_admin_application_path, class: 'btn btn-success' +%p= link_to 'New application', new_admin_application_path, class: 'gl-button btn btn-success' %table.table %thead %tr @@ -23,6 +23,6 @@ %td= @application_counts[application.id].to_i %td= application.trusted? ? 'Y': 'N' %td= application.confidential? ? 'Y': 'N' - %td= link_to 'Edit', edit_admin_application_path(application), class: 'btn btn-link' + %td= link_to 'Edit', edit_admin_application_path(application), class: 'gl-button btn btn-link' %td= render 'delete_form', application: application = paginate @applications, theme: 'gitlab' diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml index 5259dd56df5..f029da6b3af 100644 --- a/app/views/admin/applications/show.html.haml +++ b/app/views/admin/applications/show.html.haml @@ -13,7 +13,7 @@ .input-group %input.label.label-monospace.monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true } .input-group-append - = clipboard_button(target: '#application_id', title: _("Copy ID"), class: "btn btn btn-default") + = clipboard_button(target: '#application_id', title: _("Copy ID"), class: "gl-button btn btn-default") %tr %td = _('Secret') @@ -22,7 +22,7 @@ .input-group %input.label.label-monospace.monospace{ id: "secret", type: "text", autocomplete: 'off', value: @application.secret, readonly: true } .input-group-append - = clipboard_button(target: '#secret', title: _("Copy secret"), class: "btn btn btn-default") + = clipboard_button(target: '#secret', title: _("Copy secret"), class: "gl-button btn btn-default") %tr %td = _('Callback URL') @@ -45,5 +45,5 @@ = render "shared/tokens/scopes_list", token: @application .form-actions - = link_to 'Edit', edit_admin_application_path(@application), class: 'btn btn-primary wide float-left' + = link_to 'Edit', edit_admin_application_path(@application), class: 'gl-button btn btn-primary wide float-left' = render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger gl-ml-3' diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml index 1feb2ad16ad..6174da14ac0 100644 --- a/app/views/admin/groups/_form.html.haml +++ b/app/views/admin/groups/_form.html.haml @@ -29,10 +29,10 @@ .gl-alert-body = render 'shared/group_tips' .form-actions - = f.submit _('Create group'), class: "btn btn-success" - = link_to _('Cancel'), admin_groups_path, class: "btn btn-cancel" + = f.submit _('Create group'), class: "gl-button btn btn-success" + = link_to _('Cancel'), admin_groups_path, class: "gl-button btn btn-cancel" - else .form-actions - = f.submit _('Save changes'), class: "btn btn-success", data: { qa_selector: 'save_changes_button' } - = link_to _('Cancel'), admin_group_path(@group), class: "btn btn-cancel" + = f.submit _('Save changes'), class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' } + = link_to _('Cancel'), admin_group_path(@group), class: "gl-button btn btn-cancel" diff --git a/app/views/admin/groups/index.html.haml b/app/views/admin/groups/index.html.haml index b8fd110461d..bc4d4e489ce 100644 --- a/app/views/admin/groups/index.html.haml +++ b/app/views/admin/groups/index.html.haml @@ -10,7 +10,7 @@ = search_field_tag :name, project_name, class: "form-control search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: 'Search by name', data: { qa_selector: 'group_search_field' } = sprite_icon('search', css_class: 'search-icon') = render "shared/groups/dropdown", options_hash: admin_groups_sort_options_hash - = link_to new_admin_group_path, class: "btn btn-success" do + = link_to new_admin_group_path, class: "gl-button btn btn-success" do = _('New group') %ul.content-list = render @groups diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index dc43b45195e..424251f543e 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -115,7 +115,7 @@ .gl-mt-3 = select_tag :access_level, options_for_select(@group.access_level_roles), class: "project-access-select select2" %hr - = button_tag _('Add users to group'), class: "btn btn-success" + = button_tag _('Add users to group'), class: "gl-button btn btn-success" = render 'shared/members/requests', membership_source: @group, requesters: @requesters, force_mobile_view: true .card diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml index 65d3c78ec11..76e4fa971a3 100644 --- a/app/views/admin/health_check/show.html.haml +++ b/app/views/admin/health_check/show.html.haml @@ -9,7 +9,7 @@ %code#health-check-token= Gitlab::CurrentSettings.health_check_access_token .gl-mt-3 = button_to _("Reset health check access token"), reset_health_check_token_admin_application_settings_path, - method: :put, class: 'btn btn-default', + method: :put, class: 'gl-button btn btn-default', data: { confirm: _('Are you sure you want to reset the health check token?') } %p.light #{ _('Health information can be retrieved from the following endpoints. More information is available') } diff --git a/app/views/admin/identities/_form.html.haml b/app/views/admin/identities/_form.html.haml index 40a7014e143..5c62cff27c7 100644 --- a/app/views/admin/identities/_form.html.haml +++ b/app/views/admin/identities/_form.html.haml @@ -14,5 +14,5 @@ = f.text_field :extern_uid, class: 'form-control', required: true .form-actions - = f.submit _('Save changes'), class: "btn btn-success" + = f.submit _('Save changes'), class: "gl-button btn btn-success" diff --git a/app/views/admin/identities/_identity.html.haml b/app/views/admin/identities/_identity.html.haml index 5ed59809db5..d8facbb780a 100644 --- a/app/views/admin/identities/_identity.html.haml +++ b/app/views/admin/identities/_identity.html.haml @@ -4,9 +4,9 @@ %td = identity.extern_uid %td - = link_to edit_admin_user_identity_path(@user, identity), class: 'btn btn-sm btn-grouped' do + = link_to edit_admin_user_identity_path(@user, identity), class: 'gl-button btn btn-sm btn-grouped' do = _("Edit") = link_to [:admin, @user, identity], method: :delete, - class: 'btn btn-sm btn-danger', + class: 'gl-button btn btn-sm btn-danger', data: { confirm: _("Are you sure you want to remove this identity?") } do = _('Delete') diff --git a/app/views/admin/identities/index.html.haml b/app/views/admin/identities/index.html.haml index 9543bbcf977..a6d562dad31 100644 --- a/app/views/admin/identities/index.html.haml +++ b/app/views/admin/identities/index.html.haml @@ -3,7 +3,7 @@ - page_title _("Identities"), @user.name, _("Users") = render 'admin/users/head' -= link_to _('New identity'), new_admin_user_identity_path, class: 'float-right btn btn-success' += link_to _('New identity'), new_admin_user_identity_path, class: 'float-right gl-button btn btn-success' - if @identities.present? .table-holder %table.table diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml index 08e668e8623..bcf09dfc0d2 100644 --- a/app/views/admin/projects/index.html.haml +++ b/app/views/admin/projects/index.html.haml @@ -30,8 +30,8 @@ = dropdown_content = dropdown_loading = render 'shared/projects/dropdown' - = link_to new_project_path, class: 'btn btn-success' do + = link_to new_project_path, class: 'gl-button btn btn-success' do New Project - = button_tag "Search", class: "btn btn-primary btn-search hide" + = button_tag "Search", class: "gl-button btn btn-primary btn-search hide" = render 'projects' diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index 01a0b4d295d..417fd1d60eb 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -149,7 +149,7 @@ .form-group.row .offset-sm-3.col-sm-9 - = f.submit _('Transfer'), class: 'btn btn-primary' + = f.submit _('Transfer'), class: 'gl-button btn btn-primary' .card.repository-check .card-header @@ -169,7 +169,7 @@ = link_to sprite_icon('question-o'), help_page_path('administration/repository_checks') .form-group - = f.submit _('Trigger repository check'), class: 'btn btn-primary' + = f.submit _('Trigger repository check'), class: 'gl-button btn btn-primary' .col-md-6 - if @group diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml index a2b736c332c..cc8ac6b0642 100644 --- a/app/views/admin/runners/_runner.html.haml +++ b/app/views/admin/runners/_runner.html.haml @@ -65,15 +65,15 @@ .table-section.table-button-footer.section-10 .btn-group.table-action-buttons .btn-group - = link_to admin_runner_path(runner), class: 'btn btn-default has-tooltip', title: _('Edit'), ref: 'tooltip', aria: { label: _('Edit') }, data: { placement: 'top', container: 'body'} do + = link_to admin_runner_path(runner), class: 'gl-button btn btn-default has-tooltip', title: _('Edit'), ref: 'tooltip', aria: { label: _('Edit') }, data: { placement: 'top', container: 'body'} do = sprite_icon('pencil') .btn-group - if runner.active? - = link_to [:pause, :admin, runner], method: :get, class: 'btn btn-default btn-svg has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do + = link_to [:pause, :admin, runner], method: :get, class: 'gl-button btn btn-default btn-svg has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do = sprite_icon('pause') - else - = link_to [:resume, :admin, runner], method: :get, class: 'btn btn-default btn-svg has-tooltip gl-px-3', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do + = link_to [:resume, :admin, runner], method: :get, class: 'gl-button btn btn-default btn-svg has-tooltip gl-px-3', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do = sprite_icon('play') .btn-group - = link_to [:admin, runner], method: :delete, class: 'btn btn-danger has-tooltip', title: _('Remove'), ref: 'tooltip', aria: { label: _('Remove') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do + = link_to [:admin, runner], method: :delete, class: 'gl-button btn btn-danger has-tooltip', title: _('Remove'), ref: 'tooltip', aria: { label: _('Remove') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do = sprite_icon('close') diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index cc218aefdb7..3d3b8c28a17 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -48,7 +48,7 @@ .filtered-search-box = dropdown_tag(_('Recent searches'), options: { wrapper_class: 'filtered-search-history-dropdown-wrapper', - toggle_class: 'btn filtered-search-history-dropdown-toggle-button', + toggle_class: 'gl-button btn filtered-search-history-dropdown-toggle-button', dropdown_class: 'filtered-search-history-dropdown', content_class: 'filtered-search-history-dropdown-content' }) do .js-filtered-search-history-dropdown{ data: { full_path: admin_runners_path } } @@ -60,7 +60,7 @@ #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } } - = button_tag class: %w[btn btn-link] do + = button_tag class: %w[gl-button btn btn-link] do -# Encapsulate static class name `{{icon}}` inside #{} to bypass -# haml lint's ClassAttributeWithStaticValue %svg @@ -78,21 +78,21 @@ %ul{ data: { dropdown: true } } - Ci::Runner::AVAILABLE_STATUSES.each do |status| %li.filter-dropdown-item{ data: { value: status } } - = button_tag class: %w[btn btn-link] do + = button_tag class: %w[gl-button btn btn-link] do = status.titleize #js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } - Ci::Runner::AVAILABLE_TYPES.each do |runner_type| %li.filter-dropdown-item{ data: { value: runner_type } } - = button_tag class: %w[btn btn-link] do + = button_tag class: %w[gl-button btn btn-link] do = runner_type.titleize #js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } - Ci::Runner::AVAILABLE_TYPES.each do |runner_type| %li.filter-dropdown-item{ data: { value: runner_type } } - = button_tag class: %w[btn btn-link] do + = button_tag class: %w[gl-button btn btn-link] do = runner_type.titleize #js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml index cecf3f137ed..2c4befb1be2 100644 --- a/app/views/admin/runners/show.html.haml +++ b/app/views/admin/runners/show.html.haml @@ -49,7 +49,7 @@ = project.full_name %td .float-right - = link_to 'Disable', admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'btn btn-danger btn-sm' + = link_to 'Disable', admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'gl-button btn btn-danger btn-sm' %table.table.unassigned-projects %thead @@ -73,7 +73,7 @@ .float-right = form_for project.runner_projects.new, url: admin_namespace_project_runner_projects_path(project.namespace, project), method: :post do |f| = f.hidden_field :runner_id, value: @runner.id - = f.submit 'Enable', class: 'btn btn-sm' + = f.submit 'Enable', class: 'gl-button btn btn-sm' = paginate_without_count @projects .col-md-6 diff --git a/app/views/admin/serverless/domains/_form.html.haml b/app/views/admin/serverless/domains/_form.html.haml index fab1795e136..8f0dd0cab8e 100644 --- a/app/views/admin/serverless/domains/_form.html.haml +++ b/app/views/admin/serverless/domains/_form.html.haml @@ -16,7 +16,7 @@ - text, status = @domain.unverified? ? [_('Unverified'), 'badge-danger'] : [_('Verified'), 'badge-success'] .badge{ class: status } = text - = link_to sprite_icon("redo"), verify_admin_serverless_domain_path(@domain.id), method: :post, class: "btn has-tooltip", title: _("Retry verification") + = link_to sprite_icon("redo"), verify_admin_serverless_domain_path(@domain.id), method: :post, class: "gl-button btn has-tooltip", title: _("Retry verification") .col-sm-6 = f.label :serverless_domain_dns, _('DNS'), class: 'label-bold' @@ -65,7 +65,7 @@ %span.form-text.text-muted = _("Upload a private key for your certificate") - = f.submit @domain.persisted? ? _('Save changes') : _('Add domain'), class: "btn btn-success js-serverless-domain-submit", disabled: @domain.persisted? + = f.submit @domain.persisted? ? _('Save changes') : _('Add domain'), class: "gl-button btn btn-success js-serverless-domain-submit", disabled: @domain.persisted? - if @domain.persisted? %button.gl-button.btn.btn-danger{ type: 'button', data: { toggle: 'modal', target: "#modal-delete-domain" } } = _('Delete domain') @@ -88,7 +88,7 @@ = _("You are about to delete %{domain} from your instance. This domain will no longer be available to any Knative application.").html_safe % { domain: "<code>#{@domain.domain}</code>".html_safe } .modal-footer - %a{ href: '#', data: { dismiss: 'modal' }, class: 'btn btn-default' } + %a{ href: '#', data: { dismiss: 'modal' }, class: 'gl-button btn btn-default' } = _('Cancel') = link_to _('Delete domain'), diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml index 5be1c90d6aa..47ef4f26889 100644 --- a/app/views/admin/sessions/_new_base.html.haml +++ b/app/views/admin/sessions/_new_base.html.haml @@ -4,4 +4,4 @@ = password_field_tag 'user[password]', nil, class: 'form-control', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' } .submit-container.move-submit-down - = submit_tag _('Enter Admin Mode'), class: 'btn btn-success', data: { qa_selector: 'enter_admin_mode_button' } + = submit_tag _('Enter Admin Mode'), class: 'gl-button btn btn-success', data: { qa_selector: 'enter_admin_mode_button' } diff --git a/app/views/admin/sessions/_two_factor_otp.html.haml b/app/views/admin/sessions/_two_factor_otp.html.haml index 8d5588de06e..3fe6e20a367 100644 --- a/app/views/admin/sessions/_two_factor_otp.html.haml +++ b/app/views/admin/sessions/_two_factor_otp.html.haml @@ -6,4 +6,4 @@ = _("Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes.") .submit-container.move-submit-down - = submit_tag 'Verify code', class: 'btn btn-success' + = submit_tag 'Verify code', class: 'gl-button btn btn-success' diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml index a381efcb0f2..2e7114ddab4 100644 --- a/app/views/admin/spam_logs/_spam_log.html.haml +++ b/app/views/admin/spam_logs/_spam_log.html.haml @@ -30,10 +30,10 @@ .btn.btn-sm.disabled Submitted as ham - else - = link_to 'Submit as ham', mark_as_ham_admin_spam_log_path(spam_log), method: :post, class: 'btn btn-sm btn-warning' + = link_to 'Submit as ham', mark_as_ham_admin_spam_log_path(spam_log), method: :post, class: 'gl-button btn btn-sm btn-warning' - if user && !user.blocked? - = link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-sm" + = link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "gl-button btn btn-sm" - else .btn.btn-sm.disabled Already blocked - = link_to 'Remove log', [:admin, spam_log], remote: true, method: :delete, class: "btn btn-sm btn-close js-remove-tr" + = link_to 'Remove log', [:admin, spam_log], remote: true, method: :delete, class: "gl-button btn btn-sm btn-close js-remove-tr" diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml index 9e31c8d2852..61c31d2d864 100644 --- a/app/views/admin/users/_form.html.haml +++ b/app/views/admin/users/_form.html.haml @@ -88,7 +88,7 @@ .form-actions - if @user.new_record? = f.submit 'Create user', class: "btn gl-button btn-success" - = link_to 'Cancel', admin_users_path, class: "btn btn-cancel" + = link_to 'Cancel', admin_users_path, class: "gl-button btn btn-cancel" - else = f.submit 'Save changes', class: "btn gl-button btn-success" = link_to 'Cancel', admin_user_path(@user), class: "btn gl-button btn-cancel" diff --git a/app/views/jira_connect/subscriptions/index.html.haml b/app/views/jira_connect/subscriptions/index.html.haml index 32dd9a7c275..349b3328c12 100644 --- a/app/views/jira_connect/subscriptions/index.html.haml +++ b/app/views/jira_connect/subscriptions/index.html.haml @@ -24,5 +24,14 @@ %td= subscription.created_at %td= link_to 'Remove', jira_connect_subscription_path(subscription), class: 'remove-subscription' +%p + %strong Browser limitations: + Adding a namespace currently works only in browsers that allow cross site cookies. Please make sure to use + %a{ href: 'https://www.mozilla.org/en-US/firefox/', target: '_blank', rel: 'noopener noreferrer' } Firefox + or + %a{ href: 'https://www.google.com/chrome/index.html', target: '_blank', rel: 'noopener noreferrer' } Google Chrome + or enable cross-site cookies in your browser when adding a namespace. + %a{ href: 'https://gitlab.com/gitlab-org/gitlab/-/issues/263509', target: '_blank', rel: 'noopener noreferrer' } Learn more + = page_specific_javascript_tag('jira_connect.js') - add_page_specific_style 'page_bundles/jira_connect' diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index a068643cdfe..d3d71f91176 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -297,7 +297,11 @@ %span = _('Environments') - = render_if_exists 'layouts/nav/sidebar/project_feature_flags_link' + - if project_nav_tab? :feature_flags + = nav_link(controller: :feature_flags) do + = link_to project_feature_flags_path(@project), title: _('Feature Flags'), class: 'shortcuts-feature-flags' do + %span + = _('Feature Flags') - if project_nav_tab?(:product_analytics) = nav_link(controller: :product_analytics) do diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index 5b7a0b99598..9baa340376b 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -12,7 +12,7 @@ .timeline-entry-inner .flash-container.timeline-content - .timeline-icon.d-none.d-sm-none.d-md-block + .timeline-icon.d-none.d-md-block %a.author-link{ href: user_path(current_user) } = image_tag avatar_icon_for_user(current_user), alt: current_user.to_reference, class: 'avatar s40' .timeline-content.timeline-content-form diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index efb766e1f0b..1e2cded0618 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -147,6 +147,14 @@ :weight: 1 :idempotent: :tags: [] +- :name: cronjob:ci_schedule_delete_objects_cron + :feature_category: :continuous_integration + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:container_expiration_policy :feature_category: :container_registry :has_external_dependencies: @@ -1305,6 +1313,14 @@ :idempotent: :tags: - :requires_disk_io +- :name: ci_delete_objects + :feature_category: :continuous_integration + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: create_commit_signature :feature_category: :source_code_management :has_external_dependencies: diff --git a/app/workers/ci/delete_objects_worker.rb b/app/workers/ci/delete_objects_worker.rb new file mode 100644 index 00000000000..e34be33b438 --- /dev/null +++ b/app/workers/ci/delete_objects_worker.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Ci + class DeleteObjectsWorker + include ApplicationWorker + include LimitedCapacity::Worker + + feature_category :continuous_integration + idempotent! + + def perform_work(*args) + service.execute + end + + def remaining_work_count(*args) + @remaining_work_count ||= service + .remaining_batches_count(max_batch_count: remaining_capacity) + end + + def max_running_jobs + if ::Feature.enabled?(:ci_delete_objects_low_concurrency) + 2 + elsif ::Feature.enabled?(:ci_delete_objects_medium_concurrency) + 20 + elsif ::Feature.enabled?(:ci_delete_objects_high_concurrency) + 50 + else + 0 + end + end + + private + + def service + @service ||= DeleteObjectsService.new + end + end +end diff --git a/app/workers/ci/schedule_delete_objects_cron_worker.rb b/app/workers/ci/schedule_delete_objects_cron_worker.rb new file mode 100644 index 00000000000..fa0b15deb56 --- /dev/null +++ b/app/workers/ci/schedule_delete_objects_cron_worker.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Ci + class ScheduleDeleteObjectsCronWorker + include ApplicationWorker + # rubocop:disable Scalability/CronWorkerContext + # This worker does not perform work scoped to a context + include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext + + feature_category :continuous_integration + idempotent! + + def perform(*args) + Ci::DeleteObjectsWorker.perform_with_capacity(*args) + end + end +end diff --git a/changelogs/unreleased/198-add-automation-friendly-migration-rake-tasks.yml b/changelogs/unreleased/198-add-automation-friendly-migration-rake-tasks.yml new file mode 100644 index 00000000000..77a51de52b2 --- /dev/null +++ b/changelogs/unreleased/198-add-automation-friendly-migration-rake-tasks.yml @@ -0,0 +1,5 @@ +--- +title: Add unattended database migration option +merge_request: 44392 +author: +type: added diff --git a/changelogs/unreleased/216881-add-close-button-to-sidebar-labels-to-remove.yml b/changelogs/unreleased/216881-add-close-button-to-sidebar-labels-to-remove.yml new file mode 100644 index 00000000000..12599aeec42 --- /dev/null +++ b/changelogs/unreleased/216881-add-close-button-to-sidebar-labels-to-remove.yml @@ -0,0 +1,5 @@ +--- +title: Add close button to issue, MR, and epic sidebar labels +merge_request: 42703 +author: +type: added diff --git a/changelogs/unreleased/263484-integration-descriptions-should-be-less-project-level-specific.yml b/changelogs/unreleased/263484-integration-descriptions-should-be-less-project-level-specific.yml new file mode 100644 index 00000000000..1a804edec88 --- /dev/null +++ b/changelogs/unreleased/263484-integration-descriptions-should-be-less-project-level-specific.yml @@ -0,0 +1,5 @@ +--- +title: Update integration descriptions to not be project-specific +merge_request: 44893 +author: +type: changed diff --git a/changelogs/unreleased/263509_add_cross_site_cookies_browser_limitaion_message.yml b/changelogs/unreleased/263509_add_cross_site_cookies_browser_limitaion_message.yml new file mode 100644 index 00000000000..9ce503a8c9d --- /dev/null +++ b/changelogs/unreleased/263509_add_cross_site_cookies_browser_limitaion_message.yml @@ -0,0 +1,5 @@ +--- +title: Add note about cross site cookies browser limitaion to Jira App page +merge_request: 44898 +author: +type: fixed diff --git a/changelogs/unreleased/264790-bs4-optimization-commit-2.yml b/changelogs/unreleased/264790-bs4-optimization-commit-2.yml new file mode 100644 index 00000000000..ff9ccf25716 --- /dev/null +++ b/changelogs/unreleased/264790-bs4-optimization-commit-2.yml @@ -0,0 +1,5 @@ +--- +title: Remove duplicated BS display property from Commit/Snippet's HAML +merge_request: 44917 +author: Takuya Noguchi +type: other diff --git a/changelogs/unreleased/add-ci-deleted-objects-table.yml b/changelogs/unreleased/add-ci-deleted-objects-table.yml new file mode 100644 index 00000000000..7576fe52d70 --- /dev/null +++ b/changelogs/unreleased/add-ci-deleted-objects-table.yml @@ -0,0 +1,5 @@ +--- +title: Parallelize removal of expired artifacts +merge_request: 39464 +author: +type: changed diff --git a/changelogs/unreleased/feature-flags-flexible-rollout-ux.yml b/changelogs/unreleased/feature-flags-flexible-rollout-ux.yml new file mode 100644 index 00000000000..0f92f7f3d3c --- /dev/null +++ b/changelogs/unreleased/feature-flags-flexible-rollout-ux.yml @@ -0,0 +1,5 @@ +--- +title: Adds flexible rollout strategy UX and documentation +merge_request: 43611 +author: +type: added diff --git a/changelogs/unreleased/latest-successful-build-including-child-pipelines.yml b/changelogs/unreleased/latest-successful-build-including-child-pipelines.yml new file mode 100644 index 00000000000..386795ba14d --- /dev/null +++ b/changelogs/unreleased/latest-successful-build-including-child-pipelines.yml @@ -0,0 +1,5 @@ +--- +title: Include builds from child pipelines in latest sucessful build for ref/sha +merge_request: 29710 +author: +type: fixed diff --git a/changelogs/unreleased/lm-add-status-graphql.yml b/changelogs/unreleased/lm-add-status-graphql.yml new file mode 100644 index 00000000000..52297c8d0a9 --- /dev/null +++ b/changelogs/unreleased/lm-add-status-graphql.yml @@ -0,0 +1,5 @@ +--- +title: 'GrahphQL: Adds status to jobs, stages, and groups' +merge_request: 43069 +author: +type: added diff --git a/changelogs/unreleased/move-ff-menu-doc-to-core.yml b/changelogs/unreleased/move-ff-menu-doc-to-core.yml new file mode 100644 index 00000000000..10939c306c4 --- /dev/null +++ b/changelogs/unreleased/move-ff-menu-doc-to-core.yml @@ -0,0 +1,5 @@ +--- +title: Move feature flags to core +merge_request: 44642 +author: +type: changed diff --git a/changelogs/unreleased/mw-project-settings-icon-replacements.yml b/changelogs/unreleased/mw-project-settings-icon-replacements.yml new file mode 100644 index 00000000000..d7478f3386f --- /dev/null +++ b/changelogs/unreleased/mw-project-settings-icon-replacements.yml @@ -0,0 +1,5 @@ +--- +title: Replace fa-chevron-down with GitLab SVG in project visibility settings +merge_request: 45021 +author: +type: changed diff --git a/changelogs/unreleased/sh-improve-pre-receive-error-ff-merge-message.yml b/changelogs/unreleased/sh-improve-pre-receive-error-ff-merge-message.yml new file mode 100644 index 00000000000..af77bfcc41c --- /dev/null +++ b/changelogs/unreleased/sh-improve-pre-receive-error-ff-merge-message.yml @@ -0,0 +1,5 @@ +--- +title: Improve merge error when pre-receive hooks fail in fast-forward merge +merge_request: 44843 +author: +type: fixed diff --git a/changelogs/unreleased/sh-update-rack-2-1-4.yml b/changelogs/unreleased/sh-update-rack-2-1-4.yml new file mode 100644 index 00000000000..ac0fb46cbf3 --- /dev/null +++ b/changelogs/unreleased/sh-update-rack-2-1-4.yml @@ -0,0 +1,5 @@ +--- +title: Update to Rack v2.1.4 +merge_request: 44518 +author: +type: fixed diff --git a/config/feature_flags/development/ci_delete_objects_high_concurrency.yml b/config/feature_flags/development/ci_delete_objects_high_concurrency.yml new file mode 100644 index 00000000000..c2b391f8b8f --- /dev/null +++ b/config/feature_flags/development/ci_delete_objects_high_concurrency.yml @@ -0,0 +1,7 @@ +--- +name: ci_delete_objects_high_concurrency +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39464 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/247103 +group: group::continuous integration +type: development +default_enabled: false
\ No newline at end of file diff --git a/config/feature_flags/development/ci_delete_objects_low_concurrency.yml b/config/feature_flags/development/ci_delete_objects_low_concurrency.yml new file mode 100644 index 00000000000..cc59e0e3f6f --- /dev/null +++ b/config/feature_flags/development/ci_delete_objects_low_concurrency.yml @@ -0,0 +1,7 @@ +--- +name: ci_delete_objects_low_concurrency +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39464 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/247103 +group: group::continuous integration +type: development +default_enabled: false
\ No newline at end of file diff --git a/config/feature_flags/development/ci_delete_objects_medium_concurrency.yml b/config/feature_flags/development/ci_delete_objects_medium_concurrency.yml new file mode 100644 index 00000000000..4b72980945c --- /dev/null +++ b/config/feature_flags/development/ci_delete_objects_medium_concurrency.yml @@ -0,0 +1,7 @@ +--- +name: ci_delete_objects_medium_concurrency +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39464 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/247103 +group: group::continuous integration +type: development +default_enabled: false
\ No newline at end of file diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index cce627fa540..bef6d4e48d1 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -435,6 +435,9 @@ production: &base # Remove expired build artifacts expire_build_artifacts_worker: cron: "50 * * * *" + # Remove files from object storage + ci_schedule_delete_objects_worker: + cron: "*/16 * * * *" # Stop expired environments environments_auto_stop_cron_worker: cron: "24 * * * *" diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 21d5451d9b4..d8fa6f0179e 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -416,6 +416,9 @@ Settings.cron_jobs['pipeline_schedule_worker']['job_class'] = 'PipelineScheduleW Settings.cron_jobs['expire_build_artifacts_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['expire_build_artifacts_worker']['cron'] ||= '50 * * * *' Settings.cron_jobs['expire_build_artifacts_worker']['job_class'] = 'ExpireBuildArtifactsWorker' +Settings.cron_jobs['ci_schedule_delete_objects_worker'] ||= Settingslogic.new({}) +Settings.cron_jobs['ci_schedule_delete_objects_worker']['cron'] ||= '*/16 * * * *' +Settings.cron_jobs['ci_schedule_delete_objects_worker']['job_class'] = 'Ci::ScheduleDeleteObjectsCronWorker' Settings.cron_jobs['environments_auto_stop_cron_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['environments_auto_stop_cron_worker']['cron'] ||= '24 * * * *' Settings.cron_jobs['environments_auto_stop_cron_worker']['job_class'] = 'Environments::AutoStopCronWorker' diff --git a/config/routes/group.rb b/config/routes/group.rb index 7b74ccbe1b9..8d84a02fd9a 100644 --- a/config/routes/group.rb +++ b/config/routes/group.rb @@ -87,7 +87,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do delete :leave, on: :collection end - resources :group_links, only: [:create, :update, :destroy], constraints: { id: /\d+/ } + resources :group_links, only: [:create, :update, :destroy], constraints: { id: /\d+|:id/ } resources :uploads, only: [:create] do collection do diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index d4d17e692a4..364479da209 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -50,6 +50,8 @@ - 2 - - ci_batch_reset_minutes - 1 +- - ci_delete_objects + - 1 - - container_repository - 1 - - create_commit_signature diff --git a/db/migrate/20200813135558_create_ci_deleted_objects.rb b/db/migrate/20200813135558_create_ci_deleted_objects.rb new file mode 100644 index 00000000000..5364b7fc0ce --- /dev/null +++ b/db/migrate/20200813135558_create_ci_deleted_objects.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class CreateCiDeletedObjects < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + create_table :ci_deleted_objects, if_not_exists: true do |t| + t.integer :file_store, limit: 2, default: 1, null: false + t.datetime_with_timezone :pick_up_at, null: false, default: -> { 'now()' }, index: true + t.text :store_dir, null: false + + # rubocop:disable Migration/AddLimitToTextColumns + # This column depends on the `file` column from `ci_job_artifacts` table + # which doesn't have a constraint limit on it. + t.text :file, null: false + # rubocop:enable Migration/AddLimitToTextColumns + end + + add_text_limit(:ci_deleted_objects, :store_dir, 1024) + end + + def down + drop_table :ci_deleted_objects + end +end diff --git a/db/schema_migrations/20200813135558 b/db/schema_migrations/20200813135558 new file mode 100644 index 00000000000..319f0a0b604 --- /dev/null +++ b/db/schema_migrations/20200813135558 @@ -0,0 +1 @@ +5f7a5fa697d769f5ccc9f0a6f19a91c8935f2559e019d50895574819494baf7e
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index bd4470faa30..7ac26402d8e 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -10074,6 +10074,24 @@ CREATE SEQUENCE ci_daily_build_group_report_results_id_seq ALTER SEQUENCE ci_daily_build_group_report_results_id_seq OWNED BY ci_daily_build_group_report_results.id; +CREATE TABLE ci_deleted_objects ( + id bigint NOT NULL, + file_store smallint DEFAULT 1 NOT NULL, + pick_up_at timestamp with time zone DEFAULT now() NOT NULL, + store_dir text NOT NULL, + file text NOT NULL, + CONSTRAINT check_5e151d6912 CHECK ((char_length(store_dir) <= 1024)) +); + +CREATE SEQUENCE ci_deleted_objects_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE ci_deleted_objects_id_seq OWNED BY ci_deleted_objects.id; + CREATE TABLE ci_freeze_periods ( id bigint NOT NULL, project_id bigint NOT NULL, @@ -17293,6 +17311,8 @@ ALTER TABLE ONLY ci_builds_runner_session ALTER COLUMN id SET DEFAULT nextval('c ALTER TABLE ONLY ci_daily_build_group_report_results ALTER COLUMN id SET DEFAULT nextval('ci_daily_build_group_report_results_id_seq'::regclass); +ALTER TABLE ONLY ci_deleted_objects ALTER COLUMN id SET DEFAULT nextval('ci_deleted_objects_id_seq'::regclass); + ALTER TABLE ONLY ci_freeze_periods ALTER COLUMN id SET DEFAULT nextval('ci_freeze_periods_id_seq'::regclass); ALTER TABLE ONLY ci_group_variables ALTER COLUMN id SET DEFAULT nextval('ci_group_variables_id_seq'::regclass); @@ -18282,6 +18302,9 @@ ALTER TABLE ONLY ci_builds_runner_session ALTER TABLE ONLY ci_daily_build_group_report_results ADD CONSTRAINT ci_daily_build_group_report_results_pkey PRIMARY KEY (id); +ALTER TABLE ONLY ci_deleted_objects + ADD CONSTRAINT ci_deleted_objects_pkey PRIMARY KEY (id); + ALTER TABLE ONLY ci_freeze_periods ADD CONSTRAINT ci_freeze_periods_pkey PRIMARY KEY (id); @@ -19820,6 +19843,8 @@ CREATE UNIQUE INDEX index_ci_builds_runner_session_on_build_id ON ci_builds_runn CREATE INDEX index_ci_daily_build_group_report_results_on_last_pipeline_id ON ci_daily_build_group_report_results USING btree (last_pipeline_id); +CREATE INDEX index_ci_deleted_objects_on_pick_up_at ON ci_deleted_objects USING btree (pick_up_at); + CREATE INDEX index_ci_freeze_periods_on_project_id ON ci_freeze_periods USING btree (project_id); CREATE UNIQUE INDEX index_ci_group_variables_on_group_id_and_key ON ci_group_variables USING btree (group_id, key); diff --git a/doc/api/feature_flags.md b/doc/api/feature_flags.md index 6ea01a34a25..fbda873f866 100644 --- a/doc/api/feature_flags.md +++ b/doc/api/feature_flags.md @@ -4,9 +4,11 @@ group: Progressive Delivery info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers --- -# Feature Flags API **(PREMIUM)** +# Feature Flags API **(CORE)** -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/9566) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.5. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/9566) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.5. +> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/212318) to [GitLab Starter](https://about.gitlab.com/pricing/) in 13.4. +> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/212318) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.5. NOTE: **Note:** This API is behind a [feature flag](../operations/feature_flags.md#enable-or-disable-feature-flag-strategies). diff --git a/doc/api/feature_flags_legacy.md b/doc/api/feature_flags_legacy.md index 175261b3a7b..a7c139a02ba 100644 --- a/doc/api/feature_flags_legacy.md +++ b/doc/api/feature_flags_legacy.md @@ -4,9 +4,11 @@ group: Progressive Delivery info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers --- -# Legacy Feature Flags API **(PREMIUM)** +# Legacy Feature Flags API **(CORE)** -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/9566) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.5. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/9566) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.5. +> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/212318) to [GitLab Starter](https://about.gitlab.com/pricing/) in 13.4. +> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/212318) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.5. CAUTION: **Deprecation:** This API is deprecated and [scheduled for removal in GitLab 14.0](https://gitlab.com/gitlab-org/gitlab/-/issues/213369). Use [this API](feature_flags.md) instead. diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 5fe7d0f5d6e..146f15cf3a7 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -2011,6 +2011,11 @@ type BurnupChartDailyTotals { type CiGroup { """ + Detailed status of the group + """ + detailedStatus: DetailedStatus + + """ Jobs in group """ jobs( @@ -2083,6 +2088,11 @@ type CiGroupEdge { type CiJob { """ + Detailed status of the job + """ + detailedStatus: DetailedStatus + + """ Name of the job """ name: String @@ -2155,6 +2165,11 @@ scalar CiPipelineID type CiStage { """ + Detailed status of the stage + """ + detailedStatus: DetailedStatus + + """ Group of jobs for the stage """ groups( @@ -5295,42 +5310,42 @@ type DetailedStatus { action: StatusAction """ - Path of the details for the pipeline status + Path of the details for the status """ - detailsPath: String! + detailsPath: String """ - Favicon of the pipeline status + Favicon of the status """ favicon: String! """ - Group of the pipeline status + Group of the status """ group: String! """ - Indicates if the pipeline status has further details + Indicates if the status has further details """ hasDetails: Boolean! """ - Icon of the pipeline status + Icon of the status """ icon: String! """ - Label of the pipeline status + Label of the status """ label: String! """ - Text of the pipeline status + Text of the status """ text: String! """ - Tooltip associated with the pipeline status + Tooltip associated with the status """ tooltip: String! } diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index c0d3befd489..25c4014a4dc 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -5368,6 +5368,20 @@ "description": null, "fields": [ { + "name": "detailedStatus", + "description": "Detailed status of the group", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "DetailedStatus", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "jobs", "description": "Jobs in group", "args": [ @@ -5574,6 +5588,20 @@ "description": null, "fields": [ { + "name": "detailedStatus", + "description": "Detailed status of the job", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "DetailedStatus", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "name", "description": "Name of the job", "args": [ @@ -5776,6 +5804,20 @@ "description": null, "fields": [ { + "name": "detailedStatus", + "description": "Detailed status of the stage", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "DetailedStatus", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "groups", "description": "Group of jobs for the stage", "args": [ @@ -14479,25 +14521,21 @@ }, { "name": "detailsPath", - "description": "Path of the details for the pipeline status", + "description": "Path of the details for the status", "args": [ ], "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } + "kind": "SCALAR", + "name": "String", + "ofType": null }, "isDeprecated": false, "deprecationReason": null }, { "name": "favicon", - "description": "Favicon of the pipeline status", + "description": "Favicon of the status", "args": [ ], @@ -14515,7 +14553,7 @@ }, { "name": "group", - "description": "Group of the pipeline status", + "description": "Group of the status", "args": [ ], @@ -14533,7 +14571,7 @@ }, { "name": "hasDetails", - "description": "Indicates if the pipeline status has further details", + "description": "Indicates if the status has further details", "args": [ ], @@ -14551,7 +14589,7 @@ }, { "name": "icon", - "description": "Icon of the pipeline status", + "description": "Icon of the status", "args": [ ], @@ -14569,7 +14607,7 @@ }, { "name": "label", - "description": "Label of the pipeline status", + "description": "Label of the status", "args": [ ], @@ -14587,7 +14625,7 @@ }, { "name": "text", - "description": "Text of the pipeline status", + "description": "Text of the status", "args": [ ], @@ -14605,7 +14643,7 @@ }, { "name": "tooltip", - "description": "Tooltip associated with the pipeline status", + "description": "Tooltip associated with the status", "args": [ ], diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 665f64f9469..11205622c82 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -324,6 +324,7 @@ Represents the total number of issues and their weights for a particular day. | Field | Type | Description | | ----- | ---- | ----------- | +| `detailedStatus` | DetailedStatus | Detailed status of the group | | `name` | String | Name of the job group | | `size` | Int | Size of the group | @@ -331,12 +332,14 @@ Represents the total number of issues and their weights for a particular day. | Field | Type | Description | | ----- | ---- | ----------- | +| `detailedStatus` | DetailedStatus | Detailed status of the job | | `name` | String | Name of the job | ### CiStage | Field | Type | Description | | ----- | ---- | ----------- | +| `detailedStatus` | DetailedStatus | Detailed status of the stage | | `name` | String | Name of the stage | ### ClusterAgent @@ -851,14 +854,14 @@ Autogenerated return type of DestroySnippet. | Field | Type | Description | | ----- | ---- | ----------- | | `action` | StatusAction | Action information for the status. This includes method, button title, icon, path, and title | -| `detailsPath` | String! | Path of the details for the pipeline status | -| `favicon` | String! | Favicon of the pipeline status | -| `group` | String! | Group of the pipeline status | -| `hasDetails` | Boolean! | Indicates if the pipeline status has further details | -| `icon` | String! | Icon of the pipeline status | -| `label` | String! | Label of the pipeline status | -| `text` | String! | Text of the pipeline status | -| `tooltip` | String! | Tooltip associated with the pipeline status | +| `detailsPath` | String | Path of the details for the status | +| `favicon` | String! | Favicon of the status | +| `group` | String! | Group of the status | +| `hasDetails` | Boolean! | Indicates if the status has further details | +| `icon` | String! | Icon of the status | +| `label` | String! | Label of the status | +| `text` | String! | Text of the status | +| `tooltip` | String! | Tooltip associated with the status | ### DiffPosition diff --git a/doc/api/job_artifacts.md b/doc/api/job_artifacts.md index 458877d6548..d6971484edc 100644 --- a/doc/api/job_artifacts.md +++ b/doc/api/job_artifacts.md @@ -63,6 +63,11 @@ the given reference name and job, provided the job finished successfully. This is the same as [getting the job's artifacts](#get-job-artifacts), but by defining the job's name instead of its ID. +NOTE: **Note:** +If a pipeline is [parent of other child pipelines](../ci/parent_child_pipelines.md), artifacts +are searched in hierarchical order from parent to child. For example, if both parent and +child pipelines have a job with the same name, the artifact from the parent pipeline will be returned. + ```plaintext GET /projects/:id/jobs/artifacts/:ref_name/download?job=name ``` @@ -157,6 +162,11 @@ Download a single artifact file for a specific job of the latest successful pipeline for the given reference name from within the job's artifacts archive. The file is extracted from the archive and streamed to the client. +In [GitLab 13.5](https://gitlab.com/gitlab-org/gitlab/-/issues/201784) and later, artifacts +for [parent and child pipelines](../ci/parent_child_pipelines.md) are searched in hierarchical +order from parent to child. For example, if both parent and child pipelines have a +job with the same name, the artifact from the parent pipeline is returned. + ```plaintext GET /projects/:id/jobs/artifacts/:ref_name/raw/*artifact_path?job=name ``` diff --git a/doc/ci/pipelines/job_artifacts.md b/doc/ci/pipelines/job_artifacts.md index 7324124d080..fc04ea87c40 100644 --- a/doc/ci/pipelines/job_artifacts.md +++ b/doc/ci/pipelines/job_artifacts.md @@ -343,6 +343,11 @@ The latest artifacts are created by jobs in the **most recent** successful pipel for the specific ref. If you run two types of pipelines for the same ref, timing determines the latest artifact. For example, if a merge request creates a branch pipeline at the same time as a scheduled pipeline, the pipeline that completed most recently creates the latest artifact. +In [GitLab 13.5](https://gitlab.com/gitlab-org/gitlab/-/issues/201784) and later, artifacts +for [parent and child pipelines](../parent_child_pipelines.md) are searched in hierarchical +order from parent to child. For example, if both parent and child pipelines have a +job with the same name, the artifact from the parent pipeline is returned. + Artifacts for other pipelines can be accessed with direct access to them. The structure of the URL to download the whole artifacts archive is the following: diff --git a/doc/development/documentation/index.md b/doc/development/documentation/index.md index 3a02ea5aa83..0b67647af3c 100644 --- a/doc/development/documentation/index.md +++ b/doc/development/documentation/index.md @@ -676,7 +676,7 @@ build pipelines: ``` We recommend installing the version of `markdownlint-cli` currently used in the documentation - linting [Docker image](https://gitlab.com/gitlab-org/gitlab-docs/-/blob/master/dockerfiles/Dockerfile.gitlab-docs-lint#L38). + linting [Docker image](https://gitlab.com/gitlab-org/gitlab-docs/-/blob/master/.gitlab-ci.yml#L420). 1. Install [`vale`](https://github.com/errata-ai/vale/releases). For example, to install using `brew` for macOS, run: @@ -686,7 +686,7 @@ build pipelines: ``` We recommend installing the version of Vale currently used in the documentation linting - [Docker image](https://gitlab.com/gitlab-org/gitlab-docs/-/blob/master/dockerfiles/Dockerfile.gitlab-docs-lint#L16). + [Docker image](https://gitlab.com/gitlab-org/gitlab-docs/-/blob/master/.gitlab-ci.yml#L419). In addition to using markdownlint and Vale at the command line, these tools can be [integrated with your code editor](#configure-editors). diff --git a/doc/install/README.md b/doc/install/README.md index 1f9ccc8115d..fcfe4e32739 100644 --- a/doc/install/README.md +++ b/doc/install/README.md @@ -16,12 +16,12 @@ and cost of hosting. There are many ways you can install GitLab depending on your platform: -1. **Omnibus GitLab**: The official deb/rpm packages that contain a bundle of GitLab +1. [**Omnibus GitLab**](#installing-gitlab-using-the-omnibus-gitlab-package-recommended): The official deb/rpm packages that contain a bundle of GitLab and the various components it depends on, like PostgreSQL, Redis, Sidekiq, etc. -1. **GitLab Helm chart**: The cloud native Helm chart for installing GitLab and all - its components on Kubernetes. -1. **Docker**: The Omnibus GitLab packages dockerized. -1. **Source**: Install GitLab and all its components from scratch. +1. [**GitLab Helm chart**](#installing-gitlab-on-kubernetes-via-the-gitlab-helm-charts): The cloud native Helm chart for installing GitLab and all its components on Kubernetes. +1. [**Docker**](#installing-gitlab-with-docker): The Omnibus GitLab packages dockerized. +1. [**Source**](#installing-gitlab-from-source): Install GitLab and all its components from scratch. +1. [**Cloud provider**](#installing-gitlab-on-cloud-providers): Install directly from platforms like AWS, Azure, GCP. TIP: **If in doubt, choose Omnibus:** The Omnibus GitLab packages are mature, diff --git a/doc/integration/jira_development_panel.md b/doc/integration/jira_development_panel.md index 6c355ec31c5..0ea30fd8178 100644 --- a/doc/integration/jira_development_panel.md +++ b/doc/integration/jira_development_panel.md @@ -263,7 +263,7 @@ The GitLab for Jira App uses an iframe to add namespaces on the settings page. S > "You need to sign in or sign up before continuing." -In this case, enable cross-site cookies in your browser. +In this case, use [Firefox](https://www.mozilla.org/en-US/firefox/), [Google Chrome](https://www.google.com/chrome/index.html) or enable cross-site cookies in your browser. ## Usage diff --git a/doc/operations/feature_flags.md b/doc/operations/feature_flags.md index c37ab760b89..db12e6eb316 100644 --- a/doc/operations/feature_flags.md +++ b/doc/operations/feature_flags.md @@ -87,12 +87,49 @@ and clicking **{pencil}** (edit). Enables the feature for all users. It uses the [`default`](https://unleash.github.io/docs/activation_strategy#default) Unleash activation strategy. +### Percent Rollout + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/43340) in GitLab 13.5. + +Enables the feature for a percentage of page views, with configurable consistency +of behavior. This consistency is also known as stickiness. It uses the +[`flexibleRollout`](https://unleash.github.io/docs/activation_strategy#flexiblerollout) +Unleash activation strategy. + +You can configure the consistency to be based on: + +- **User IDs**: Each user ID has a consistent behavior, ignoring session IDs. +- **Session IDs**: Each session ID has a consistent behavior, ignoring user IDs. +- **Random**: Consistent behavior is not guaranteed. The feature is enabled for the + selected percentage of page views randomly. User IDs and session IDs are ignored. +- **Available ID**: Consistent behavior is attempted based on the status of the user: + - If the user is logged in, make behavior consistent based on user ID. + - If the user is anonymous, make the behavior consistent based on the session ID. + - If there is no user ID or session ID, then the feature is enabled for the selected + percentage of page view randomly. + +For example, set a value of 15% based on **Available ID** to enable the feature for 15% of page views. For +authenticated users this is based on their user ID. For anonymous users with a session ID it would be based on their +session ID instead as they do not have a user ID. Then if no session ID is provided, it falls back to random. + +The rollout percentage can be from 0% to 100%. + +Selecting a consistency based on User IDs functions the same as the [percent of Users](#percent-of-users) rollout. + +CAUTION: **Caution:** +Selecting **Random** provides inconsistent application behavior for individual users. + ### Percent of Users Enables the feature for a percentage of authenticated users. It uses the [`gradualRolloutUserId`](https://unleash.github.io/docs/activation_strategy#gradualrolloutuserid) Unleash activation strategy. +NOTE: **Note:** +[Percent rollout](#percent-rollout) with a consistency based on **User IDs** has the same +behavior. It is recommended to use percent rollout instead of percent of users as +it is more flexible. + For example, set a value of 15% to enable the feature for 15% of authenticated users. The rollout percentage can be from 0% to 100%. diff --git a/doc/user/project/integrations/overview.md b/doc/user/project/integrations/overview.md index 7a1f757c138..a502dfbf320 100644 --- a/doc/user/project/integrations/overview.md +++ b/doc/user/project/integrations/overview.md @@ -50,7 +50,7 @@ Click on the service links to see further configuration instructions and details | [Mattermost slash commands](mattermost_slash_commands.md) | Mattermost chat and ChatOps slash commands | No | | [Mattermost Notifications](mattermost.md) | Receive event notifications in Mattermost | No | | [Microsoft teams](microsoft_teams.md) | Receive notifications for actions that happen on GitLab into a room on Microsoft Teams using Office 365 Connectors | No | -| Packagist | Update your project on Packagist, the main Composer repository | Yes | +| Packagist | Update your projects on Packagist, the main Composer repository | Yes | | Pipelines emails | Email the pipeline status to a list of recipients | No | | [Slack Notifications](slack.md) | Send GitLab events (for example, an issue was created) to Slack as notifications | No | | [Slack slash commands](slack_slash_commands.md) **(CORE ONLY)** | Use slash commands in Slack to control GitLab | No | diff --git a/doc/user/project/labels.md b/doc/user/project/labels.md index dd621873786..7c4bb4ae6fe 100644 --- a/doc/user/project/labels.md +++ b/doc/user/project/labels.md @@ -30,18 +30,25 @@ There are two types of labels in GitLab: ## Assign and unassign labels -Every issue, merge request and epic can be assigned any number of labels. The labels are +> Unassigning labels with the **X** button [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216881) in GitLab 13.5. + +Every issue, merge request, and epic can be assigned any number of labels. The labels are managed in the right sidebar, where you can assign or unassign labels as needed. -To assign a label to an issue, merge request or epic: +To assign or unassign a label: + +1. In the **Labels** section of the sidebar, click **Edit**. +1. In the **Assign labels** list, search for labels by typing their names. + You can search repeatedly to add more labels. + The selected labels are marked with a checkmark. +1. Click the labels you want to assign or unassign. +1. To apply your changes to labels, click **X** next to **Assign labels** or anywhere outside the + label section. -1. In the label section of the sidebar, click **Edit**, then: - - In the list, click the labels you want. Each label is flagged with a checkmark. - - Find labels by entering a search query and clicking search (**{search}**), then - click on them. You can search repeatedly and add more labels. -1. Click **X** or anywhere outside the label section and the labels are applied. +Alternatively, to unassign a label, click the **X** on the label you want to unassign. -You can also assign a label with the [`/label ~label1 ~label2` quick action](quick_actions.md). +You can also assign a label with the `/label` [quick action](quick_actions.md), +remove labels with `/unlabel`, and reassign labels (remove all and assign new ones) with `/relabel`. ## Label management diff --git a/lib/api/ci/runner.rb b/lib/api/ci/runner.rb index e293c299d75..f86e18676c1 100644 --- a/lib/api/ci/runner.rb +++ b/lib/api/ci/runner.rb @@ -72,6 +72,7 @@ module API post '/verify' do authenticate_runner! status 200 + body "200" end end @@ -183,6 +184,7 @@ module API service.execute.then do |result| header 'X-GitLab-Trace-Update-Interval', result.backoff status result.status + body result.status.to_s end end @@ -293,6 +295,7 @@ module API if result[:status] == :success status :created + body "201" else render_api_error!(result[:message], result[:http_status]) end diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 690160cd5ac..c8aee1f3479 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -522,7 +522,7 @@ module API else header(*Gitlab::Workhorse.send_url(file.url)) status :ok - body + body "" end end diff --git a/lib/api/internal/lfs.rb b/lib/api/internal/lfs.rb index adedc38b847..57cd661197c 100644 --- a/lib/api/internal/lfs.rb +++ b/lib/api/internal/lfs.rb @@ -44,7 +44,7 @@ module API workhorse_headers = Gitlab::Workhorse.send_url(file.url) header workhorse_headers[0], workhorse_headers[1] env['api.format'] = :binary - body nil + body "" end end end diff --git a/lib/backup/repositories.rb b/lib/backup/repositories.rb index c8268a14bfe..4248a86dc7c 100644 --- a/lib/backup/repositories.rb +++ b/lib/backup/repositories.rb @@ -46,9 +46,11 @@ module Backup restore_repository(project, Gitlab::GlRepository::DESIGN) end - Snippet.find_each(batch_size: 1000) do |snippet| - restore_repository(snippet, Gitlab::GlRepository::SNIPPET) - end + invalid_ids = Snippet.find_each(batch_size: 1000) + .map { |snippet| restore_snippet_repository(snippet) } + .compact + + cleanup_snippets_without_repositories(invalid_ids) restore_object_pools end @@ -192,6 +194,28 @@ module Backup end end + def restore_snippet_repository(snippet) + restore_repository(snippet, Gitlab::GlRepository::SNIPPET) + + response = Snippets::RepositoryValidationService.new(nil, snippet).execute + + if response.error? + snippet.repository.remove + + progress.puts("Snippet #{snippet.full_path} can't be restored: #{response.message}") + + snippet.id + else + nil + end + end + + # Snippets without a repository should be removed because they failed to import + # due to having invalid repositories + def cleanup_snippets_without_repositories(ids) + Snippet.id_in(ids).delete_all + end + class BackupRestore attr_accessor :progress, :repository, :backup_repos_path diff --git a/lib/gitlab/ci/status/canceled.rb b/lib/gitlab/ci/status/canceled.rb index 07f37732023..f173964b36c 100644 --- a/lib/gitlab/ci/status/canceled.rb +++ b/lib/gitlab/ci/status/canceled.rb @@ -19,6 +19,10 @@ module Gitlab def favicon 'favicon_status_canceled' end + + def details_path + nil + end end end end diff --git a/lib/gitlab/ci/status/created.rb b/lib/gitlab/ci/status/created.rb index fface4bb97b..33e67314d93 100644 --- a/lib/gitlab/ci/status/created.rb +++ b/lib/gitlab/ci/status/created.rb @@ -19,6 +19,10 @@ module Gitlab def favicon 'favicon_status_created' end + + def details_path + nil + end end end end diff --git a/lib/gitlab/ci/status/failed.rb b/lib/gitlab/ci/status/failed.rb index 770ed7d4d5a..215d27734a7 100644 --- a/lib/gitlab/ci/status/failed.rb +++ b/lib/gitlab/ci/status/failed.rb @@ -19,6 +19,10 @@ module Gitlab def favicon 'favicon_status_failed' end + + def details_path + nil + end end end end diff --git a/lib/gitlab/ci/status/manual.rb b/lib/gitlab/ci/status/manual.rb index 50c92add400..eb376df5f22 100644 --- a/lib/gitlab/ci/status/manual.rb +++ b/lib/gitlab/ci/status/manual.rb @@ -19,6 +19,10 @@ module Gitlab def favicon 'favicon_status_manual' end + + def details_path + nil + end end end end diff --git a/lib/gitlab/ci/status/pending.rb b/lib/gitlab/ci/status/pending.rb index cea7e6ed938..4280ad84534 100644 --- a/lib/gitlab/ci/status/pending.rb +++ b/lib/gitlab/ci/status/pending.rb @@ -19,6 +19,10 @@ module Gitlab def favicon 'favicon_status_pending' end + + def details_path + nil + end end end end diff --git a/lib/gitlab/ci/status/preparing.rb b/lib/gitlab/ci/status/preparing.rb index 1ebdbc482b7..e59d1d2eed1 100644 --- a/lib/gitlab/ci/status/preparing.rb +++ b/lib/gitlab/ci/status/preparing.rb @@ -19,6 +19,10 @@ module Gitlab def favicon 'favicon_status_preparing' end + + def details_path + nil + end end end end diff --git a/lib/gitlab/ci/status/running.rb b/lib/gitlab/ci/status/running.rb index ac7dd74cdce..eed1983e60e 100644 --- a/lib/gitlab/ci/status/running.rb +++ b/lib/gitlab/ci/status/running.rb @@ -19,6 +19,10 @@ module Gitlab def favicon 'favicon_status_running' end + + def details_path + nil + end end end end diff --git a/lib/gitlab/ci/status/scheduled.rb b/lib/gitlab/ci/status/scheduled.rb index 16ad1da89e3..e9068c326cf 100644 --- a/lib/gitlab/ci/status/scheduled.rb +++ b/lib/gitlab/ci/status/scheduled.rb @@ -19,6 +19,10 @@ module Gitlab def favicon 'favicon_status_scheduled' end + + def details_path + nil + end end end end diff --git a/lib/gitlab/ci/status/skipped.rb b/lib/gitlab/ci/status/skipped.rb index aaec1e1d201..238aa3ab4f9 100644 --- a/lib/gitlab/ci/status/skipped.rb +++ b/lib/gitlab/ci/status/skipped.rb @@ -19,6 +19,10 @@ module Gitlab def favicon 'favicon_status_skipped' end + + def details_path + nil + end end end end diff --git a/lib/gitlab/ci/status/success.rb b/lib/gitlab/ci/status/success.rb index 020f2c5b89f..2a10e60414e 100644 --- a/lib/gitlab/ci/status/success.rb +++ b/lib/gitlab/ci/status/success.rb @@ -19,6 +19,10 @@ module Gitlab def favicon 'favicon_status_success' end + + def details_path + nil + end end end end diff --git a/lib/gitlab/ci/status/waiting_for_resource.rb b/lib/gitlab/ci/status/waiting_for_resource.rb index 4c9e706bc51..2026148f752 100644 --- a/lib/gitlab/ci/status/waiting_for_resource.rb +++ b/lib/gitlab/ci/status/waiting_for_resource.rb @@ -23,6 +23,10 @@ module Gitlab def group 'waiting-for-resource' end + + def details_path + nil + end end end end diff --git a/lib/gitlab/ci/trace.rb b/lib/gitlab/ci/trace.rb index 6fd32b3f1a0..0222ca021b7 100644 --- a/lib/gitlab/ci/trace.rb +++ b/lib/gitlab/ci/trace.rb @@ -80,11 +80,9 @@ module Gitlab job.trace_chunks.any? || current_path.present? || old_trace.present? end - def read(should_retry: true, &block) + def read(&block) read_stream(&block) - rescue Errno::ENOENT - raise unless should_retry - + rescue Errno::ENOENT, ChunkedIO::FailedToGetChunkError job.reset read_stream(&block) end diff --git a/lib/gitlab/git/pre_receive_error.rb b/lib/gitlab/git/pre_receive_error.rb index 7a6f27179f0..b84ac656927 100644 --- a/lib/gitlab/git/pre_receive_error.rb +++ b/lib/gitlab/git/pre_receive_error.rb @@ -18,13 +18,15 @@ module Gitlab attr_reader :raw_message - def initialize(message = '', user_message = '') + def initialize(message = '', fallback_message: '') @raw_message = message - if user_message.present? - super(sanitize(user_message)) + sanitized_msg = sanitize(message) + + if sanitized_msg.present? + super(sanitized_msg) else - super(sanitize(message)) + super(fallback_message) end end diff --git a/lib/gitlab/gitaly_client/operation_service.rb b/lib/gitlab/gitaly_client/operation_service.rb index 513063c60d2..786eb3ca4ae 100644 --- a/lib/gitlab/gitaly_client/operation_service.rb +++ b/lib/gitlab/gitaly_client/operation_service.rb @@ -179,7 +179,7 @@ module Gitlab ) if response.pre_receive_error.present? - raise Gitlab::Git::PreReceiveError.new(response.pre_receive_error, "GL-HOOK-ERR: pre-receive hook failed.") + raise Gitlab::Git::PreReceiveError.new(response.pre_receive_error, fallback_message: "pre-receive hook failed.") end Gitlab::Git::OperationService::BranchUpdate.from_gitaly(response.branch_update) diff --git a/lib/tasks/gitlab/db.rake b/lib/tasks/gitlab/db.rake index 8a1809f9dfc..5f04c4f8881 100644 --- a/lib/tasks/gitlab/db.rake +++ b/lib/tasks/gitlab/db.rake @@ -65,6 +65,19 @@ namespace :gitlab do end end + desc 'GitLab | DB | Run database migrations and print `unattended_migrations_completed` if action taken' + task unattended: :environment do + no_database = !ActiveRecord::Base.connection.schema_migration.table_exists? + needs_migrations = ActiveRecord::Base.connection.migration_context.needs_migration? + + if no_database || needs_migrations + Rake::Task['gitlab:db:configure'].invoke + puts "unattended_migrations_completed" + else + puts "unattended_migrations_static" + end + end + desc 'GitLab | DB | Checks if migrations require downtime or not' task :downtime_check, [:ref] => :environment do |_, args| abort 'You must specify a Git reference to compare with' unless args[:ref] diff --git a/locale/gitlab.pot b/locale/gitlab.pot index f12c22becbd..ed78572427a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3900,6 +3900,9 @@ msgstr "" msgid "Available" msgstr "" +msgid "Available ID" +msgstr "" + msgid "Available Runners: %{runners}" msgstr "" @@ -4041,6 +4044,9 @@ msgstr "" msgid "BambooService|You must set up automatic revision labeling and a repository trigger in Bamboo." msgstr "" +msgid "Based on" +msgstr "" + msgid "Be careful. Changing the project's namespace can have unintended side effects." msgstr "" @@ -6833,7 +6839,7 @@ msgstr "" msgid "ConfluenceService|Confluence Workspace" msgstr "" -msgid "ConfluenceService|Connect a Confluence Cloud Workspace to your GitLab project" +msgid "ConfluenceService|Connect a Confluence Cloud Workspace to GitLab" msgstr "" msgid "ConfluenceService|Enabling the Confluence Workspace will disable the default GitLab Wiki. Your GitLab Wiki data will be saved and you can always re-enable it later by turning off this integration" @@ -6884,6 +6890,9 @@ msgstr "" msgid "Connection timeout" msgstr "" +msgid "Consistency guarantee method" +msgstr "" + msgid "Contact sales to upgrade" msgstr "" @@ -8139,6 +8148,9 @@ msgstr "" msgid "DastProfiles|Debug messages" msgstr "" +msgid "DastProfiles|Delete profile" +msgstr "" + msgid "DastProfiles|Do you want to discard this scanner profile?" msgstr "" @@ -9824,6 +9836,9 @@ msgstr "" msgid "Encountered an error while rendering: %{err}" msgstr "" +msgid "End Time" +msgstr "" + msgid "Ends at (UTC)" msgstr "" @@ -11041,6 +11056,18 @@ msgid_plural "FeatureFlags|%d users" msgstr[0] "" msgstr[1] "" +msgid "FeatureFlags|%{percent} by available ID" +msgstr "" + +msgid "FeatureFlags|%{percent} by session ID" +msgstr "" + +msgid "FeatureFlags|%{percent} by user ID" +msgstr "" + +msgid "FeatureFlags|%{percent} randomly" +msgstr "" + msgid "FeatureFlags|* (All Environments)" msgstr "" @@ -11071,6 +11098,9 @@ msgstr "" msgid "FeatureFlags|Configure feature flags" msgstr "" +msgid "FeatureFlags|Consider using the more flexible \"Percent rollout\" strategy instead." +msgstr "" + msgid "FeatureFlags|Create feature flag" msgstr "" @@ -11188,6 +11218,9 @@ msgstr "" msgid "FeatureFlags|Percent of users" msgstr "" +msgid "FeatureFlags|Percent rollout" +msgstr "" + msgid "FeatureFlags|Percent rollout (logged in users)" msgstr "" @@ -11251,7 +11284,7 @@ msgstr "" msgid "FeatureFlag|Select a user list" msgstr "" -msgid "FeatureFlag|Select the environment scope for this feature flag." +msgid "FeatureFlag|Select the environment scope for this feature flag" msgstr "" msgid "FeatureFlag|There are no configured user lists" @@ -14021,12 +14054,18 @@ msgstr "" msgid "Integrations|Standard" msgstr "" +msgid "Integrations|Update your projects on Packagist, the main Composer repository" +msgstr "" + msgid "Integrations|Use custom settings" msgstr "" msgid "Integrations|Use default settings" msgstr "" +msgid "Integrations|Use the GitLab Slack application" +msgstr "" + msgid "Integrations|When a Jira issue is mentioned in a commit or merge request a remote link and comment (if enabled) will be created." msgstr "" @@ -18812,6 +18851,9 @@ msgstr "" msgid "People without permission will never get a notification." msgstr "" +msgid "Percent rollout must be a whole number between 0 and 100" +msgstr "" + msgid "Percentage" msgstr "" @@ -21293,6 +21335,9 @@ msgstr "" msgid "Rake Tasks Help" msgstr "" +msgid "Random" +msgstr "" + msgid "Raw blob request rate limit per minute" msgstr "" @@ -22600,6 +22645,9 @@ msgstr "" msgid "Saving project." msgstr "" +msgid "Scanner" +msgstr "" + msgid "Schedule a new pipeline" msgstr "" @@ -23368,7 +23416,7 @@ msgstr "" msgid "Select status" msgstr "" -msgid "Select strategy activation method." +msgid "Select strategy activation method" msgstr "" msgid "Select subscription" @@ -23590,6 +23638,9 @@ msgstr "" msgid "Service URL" msgstr "" +msgid "Session ID" +msgstr "" + msgid "Session duration (minutes)" msgstr "" @@ -24698,6 +24749,9 @@ msgstr "" msgid "Start Date" msgstr "" +msgid "Start Time" +msgstr "" + msgid "Start Web Terminal" msgstr "" @@ -28252,6 +28306,9 @@ msgstr "" msgid "User %{username} was successfully removed." msgstr "" +msgid "User ID" +msgstr "" + msgid "User OAuth applications" msgstr "" @@ -30846,6 +30903,9 @@ msgstr "" msgid "is blocked by" msgstr "" +msgid "is forbidden by a top-level group" +msgstr "" + msgid "is invalid because there is downstream lock" msgstr "" @@ -30861,6 +30921,9 @@ msgstr "" msgid "is not a valid X509 certificate." msgstr "" +msgid "is not allowed since the group is not top-level group." +msgstr "" + msgid "is not allowed. Try again with a different email address, or contact your GitLab admin." msgstr "" diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 0ee773f291c..17702b00754 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -327,7 +327,7 @@ RSpec.describe Admin::UsersController do describe 'POST update' do context 'when the password has changed' do - def update_password(user, password = User.random_password, password_confirmation = password) + def update_password(user, password = User.random_password, password_confirmation = password, format = :html) params = { id: user.to_param, user: { @@ -336,7 +336,7 @@ RSpec.describe Admin::UsersController do } } - post :update, params: params + post :update, params: params, format: format end context 'when admin changes their own password' do @@ -435,6 +435,23 @@ RSpec.describe Admin::UsersController do .not_to change { user.reload.encrypted_password } end end + + context 'when the update fails' do + let(:password) { User.random_password } + + before do + expect_next_instance_of(Users::UpdateService) do |service| + allow(service).to receive(:execute).and_return({ message: 'failed', status: :error }) + end + end + + it 'returns a 500 error' do + expect { update_password(admin, password, password, :json) } + .not_to change { admin.reload.password_expired? } + + expect(response).to have_gitlab_http_status(:error) + end + end end context 'admin notes' do diff --git a/spec/controllers/groups/group_links_controller_spec.rb b/spec/controllers/groups/group_links_controller_spec.rb index d179e268748..c411d9cfb63 100644 --- a/spec/controllers/groups/group_links_controller_spec.rb +++ b/spec/controllers/groups/group_links_controller_spec.rb @@ -15,6 +15,21 @@ RSpec.describe Groups::GroupLinksController do shared_with_group.add_developer(group_member) end + shared_examples 'placeholder is passed as `id` parameter' do |action| + it 'returns a 404' do + post( + action, + params: { + group_id: shared_group, + id: ':id' + }, + format: :json + ) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + describe '#create' do let(:shared_with_group_id) { shared_with_group.id } let(:shared_group_access) { GroupGroupLink.default_access } @@ -125,6 +140,8 @@ RSpec.describe Groups::GroupLinksController do expect(response).to have_gitlab_http_status(:not_found) end end + + include_examples 'placeholder is passed as `id` parameter', :create end describe '#update' do @@ -197,6 +214,8 @@ RSpec.describe Groups::GroupLinksController do expect(response).to have_gitlab_http_status(:not_found) end end + + include_examples 'placeholder is passed as `id` parameter', :update end describe '#destroy' do @@ -232,5 +251,7 @@ RSpec.describe Groups::GroupLinksController do expect(response).to have_gitlab_http_status(:not_found) end end + + include_examples 'placeholder is passed as `id` parameter', :destroy end end diff --git a/spec/factories/ci/deleted_object.rb b/spec/factories/ci/deleted_object.rb new file mode 100644 index 00000000000..c91d259ffeb --- /dev/null +++ b/spec/factories/ci/deleted_object.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :ci_deleted_object, class: 'Ci::DeletedObject' do + pick_up_at { Time.current } + store_dir { SecureRandom.uuid } + file { fixture_file_upload(Rails.root.join('spec/fixtures/ci_build_artifacts.zip'), 'application/zip') } + end +end diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb index 5fec6dd0d78..4fa5dde4eff 100644 --- a/spec/factories/ci/pipelines.rb +++ b/spec/factories/ci/pipelines.rb @@ -15,9 +15,25 @@ FactoryBot.define do # on pipeline factories to avoid circular references transient { head_pipeline_of { nil } } + transient { child_of { nil } } + + after(:build) do |pipeline, evaluator| + if evaluator.child_of + pipeline.project = evaluator.child_of.project + pipeline.source = :parent_pipeline + end + end + after(:create) do |pipeline, evaluator| merge_request = evaluator.head_pipeline_of merge_request&.update!(head_pipeline: pipeline) + + if evaluator.child_of + bridge = create(:ci_bridge, pipeline: evaluator.child_of) + create(:ci_sources_pipeline, + source_job: bridge, + pipeline: pipeline) + end end factory :ci_pipeline do diff --git a/spec/features/issues/user_edits_issue_spec.rb b/spec/features/issues/user_edits_issue_spec.rb index 88b8e9624e2..caf8176a5d7 100644 --- a/spec/features/issues/user_edits_issue_spec.rb +++ b/spec/features/issues/user_edits_issue_spec.rb @@ -6,9 +6,10 @@ RSpec.describe "Issues > User edits issue", :js do let_it_be(:project) { create(:project_empty_repo, :public) } let_it_be(:project_with_milestones) { create(:project_empty_repo, :public) } let_it_be(:user) { create(:user) } - let_it_be(:issue) { create(:issue, project: project, author: user, assignees: [user]) } + let_it_be(:label_assigned) { create(:label, project: project, title: 'verisimilitude') } + let_it_be(:label_unassigned) { create(:label, project: project, title: 'syzygy') } + let_it_be(:issue) { create(:issue, project: project, author: user, assignees: [user], labels: [label_assigned]) } let_it_be(:issue_with_milestones) { create(:issue, project: project_with_milestones, author: user, assignees: [user]) } - let_it_be(:label) { create(:label, project: project) } let_it_be(:milestone) { create(:milestone, project: project) } let_it_be(:milestones) { create_list(:milestone, 25, project: project_with_milestones) } @@ -103,6 +104,39 @@ RSpec.describe "Issues > User edits issue", :js do expect(page).not_to have_selector('.gl-spinner') end end + + it 'can add label to issue' do + page.within '.block.labels' do + expect(page).to have_text('verisimilitude') + expect(page).not_to have_text('syzygy') + + click_on 'Edit' + + wait_for_requests + + click_on 'syzygy' + find('.dropdown-header-button').click + + wait_for_requests + + expect(page).to have_text('verisimilitude') + expect(page).to have_text('syzygy') + end + end + + it 'can remove label from issue by clicking on the label `x` button' do + page.within '.block.labels' do + expect(page).to have_text('verisimilitude') + + within '.gl-label' do + click_button + end + + wait_for_requests + + expect(page).not_to have_text('verisimilitude') + end + end end describe 'update assignee' do diff --git a/spec/features/projects/navbar_spec.rb b/spec/features/projects/navbar_spec.rb index 8731a1673b1..dcb901bcf11 100644 --- a/spec/features/projects/navbar_spec.rb +++ b/spec/features/projects/navbar_spec.rb @@ -18,14 +18,6 @@ RSpec.describe 'Project navbar' do project.add_maintainer(user) sign_in(user) - - if Gitlab.ee? - insert_after_sub_nav_item( - _('Environments'), - within: _('Operations'), - new_sub_nav_item_name: _('Feature Flags') - ) - end end it_behaves_like 'verified navigation bar' do diff --git a/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js b/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js new file mode 100644 index 00000000000..f3f70a325d0 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js @@ -0,0 +1,116 @@ +import { mount } from '@vue/test-utils'; +import { GlFormInput, GlFormSelect } from '@gitlab/ui'; +import FlexibleRollout from '~/feature_flags/components/strategies/flexible_rollout.vue'; +import ParameterFormGroup from '~/feature_flags/components/strategies/parameter_form_group.vue'; +import { PERCENT_ROLLOUT_GROUP_ID } from '~/feature_flags/constants'; +import { flexibleRolloutStrategy } from '../../mock_data'; + +const DEFAULT_PROPS = { + strategy: flexibleRolloutStrategy, +}; + +describe('feature_flags/components/strategies/flexible_rollout.vue', () => { + let wrapper; + let percentageFormGroup; + let percentageInput; + let stickinessFormGroup; + let stickinessSelect; + + const factory = (props = {}) => + mount(FlexibleRollout, { propsData: { ...DEFAULT_PROPS, ...props } }); + + afterEach(() => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = null; + }); + + describe('with valid percentage', () => { + beforeEach(() => { + wrapper = factory(); + + percentageFormGroup = wrapper + .find('[data-testid="strategy-flexible-rollout-percentage"]') + .find(ParameterFormGroup); + percentageInput = percentageFormGroup.find(GlFormInput); + stickinessFormGroup = wrapper + .find('[data-testid="strategy-flexible-rollout-stickiness"]') + .find(ParameterFormGroup); + stickinessSelect = stickinessFormGroup.find(GlFormSelect); + }); + + it('displays the current percentage value', () => { + expect(percentageInput.element.value).toBe(flexibleRolloutStrategy.parameters.rollout); + }); + + it('displays the current stickiness value', () => { + expect(stickinessSelect.element.value).toBe(flexibleRolloutStrategy.parameters.stickiness); + }); + + it('emits a change when the percentage value changes', async () => { + percentageInput.setValue('75'); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('change')).toEqual([ + [ + { + parameters: { + rollout: '75', + groupId: PERCENT_ROLLOUT_GROUP_ID, + stickiness: flexibleRolloutStrategy.parameters.stickiness, + }, + }, + ], + ]); + }); + + it('emits a change when the stickiness value changes', async () => { + stickinessSelect.setValue('USERID'); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('change')).toEqual([ + [ + { + parameters: { + rollout: flexibleRolloutStrategy.parameters.rollout, + groupId: PERCENT_ROLLOUT_GROUP_ID, + stickiness: 'USERID', + }, + }, + ], + ]); + }); + + it('does not show errors', () => { + expect(percentageFormGroup.attributes('state')).toBe('true'); + }); + }); + + describe('with percentage that is out of range', () => { + beforeEach(() => { + wrapper = factory({ strategy: { parameters: { rollout: '101' } } }); + }); + + it('shows errors', () => { + const formGroup = wrapper + .find('[data-testid="strategy-flexible-rollout-percentage"]') + .find(ParameterFormGroup); + + expect(formGroup.attributes('state')).toBeUndefined(); + }); + }); + + describe('with percentage that is not a whole number', () => { + beforeEach(() => { + wrapper = factory({ strategy: { parameters: { rollout: '3.14' } } }); + }); + + it('shows errors', () => { + const formGroup = wrapper + .find('[data-testid="strategy-flexible-rollout-percentage"]') + .find(ParameterFormGroup); + + expect(formGroup.attributes('state')).toBeUndefined(); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js b/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js index da61f5ef420..de0b439f1c5 100644 --- a/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js +++ b/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js @@ -62,4 +62,17 @@ describe('~/feature_flags/components/strategies/percent_rollout.vue', () => { expect(formGroup.attributes('state')).toBeUndefined(); }); }); + + describe('with percentage that is not a whole number', () => { + beforeEach(() => { + wrapper = factory({ strategy: { parameters: { percentage: '3.14' } } }); + + input = wrapper.find(GlFormInput); + formGroup = wrapper.find(ParameterFormGroup); + }); + + it('shows errors', () => { + expect(formGroup.attributes('state')).toBeUndefined(); + }); + }); }); diff --git a/spec/frontend/feature_flags/components/strategy_spec.js b/spec/frontend/feature_flags/components/strategy_spec.js index 04fa3c40af9..1e3e1a76afb 100644 --- a/spec/frontend/feature_flags/components/strategy_spec.js +++ b/spec/frontend/feature_flags/components/strategy_spec.js @@ -1,10 +1,11 @@ import { mount } from '@vue/test-utils'; import { last } from 'lodash'; -import { GlFormSelect, GlLink, GlToken, GlButton } from '@gitlab/ui'; +import { GlAlert, GlFormSelect, GlLink, GlToken, GlButton } from '@gitlab/ui'; import { PERCENT_ROLLOUT_GROUP_ID, ROLLOUT_STRATEGY_ALL_USERS, ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, ROLLOUT_STRATEGY_USER_ID, ROLLOUT_STRATEGY_GITLAB_USER_LIST, } from '~/feature_flags/constants'; @@ -66,6 +67,7 @@ describe('Feature flags strategy', () => { name ${ROLLOUT_STRATEGY_ALL_USERS} ${ROLLOUT_STRATEGY_PERCENT_ROLLOUT} + ${ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT} ${ROLLOUT_STRATEGY_USER_ID} ${ROLLOUT_STRATEGY_GITLAB_USER_LIST} `('with strategy $name', ({ name }) => { @@ -91,6 +93,26 @@ describe('Feature flags strategy', () => { }); }); + describe('with the gradualRolloutByUserId strategy', () => { + let strategy; + + beforeEach(() => { + strategy = { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50', groupId: 'default' }, + scopes: [{ environmentScope: 'production' }], + }; + const propsData = { strategy, index: 0, endpoint: '' }; + factory({ propsData, provide }); + }); + + it('shows an alert asking users to consider using flexibleRollout instead', () => { + expect(wrapper.find(GlAlert).text()).toContain( + 'Consider using the more flexible "Percent rollout" strategy instead.', + ); + }); + }); + describe('with a strategy', () => { describe('with a single environment scope defined', () => { let strategy; diff --git a/spec/frontend/feature_flags/mock_data.js b/spec/frontend/feature_flags/mock_data.js index d72356bad8d..ed06ea059a7 100644 --- a/spec/frontend/feature_flags/mock_data.js +++ b/spec/frontend/feature_flags/mock_data.js @@ -1,6 +1,7 @@ import { ROLLOUT_STRATEGY_ALL_USERS, ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, ROLLOUT_STRATEGY_GITLAB_USER_LIST, ROLLOUT_STRATEGY_USER_ID, } from '~/feature_flags/constants'; @@ -78,6 +79,24 @@ export const featureFlag = { }, ], }, + { + id: 5, + active: true, + environment_scope: 'development', + can_update: true, + protected: false, + created_at: '2019-01-14T06:41:40.987Z', + updated_at: '2019-01-14T06:41:40.987Z', + strategies: [ + { + name: ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, + parameters: { + rollout: '42', + stickiness: 'DEFAULT', + }, + }, + ], + }, ], }; @@ -117,6 +136,12 @@ export const percentRolloutStrategy = { scopes: [], }; +export const flexibleRolloutStrategy = { + name: ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, + parameters: { rollout: '50', groupId: 'default', stickiness: 'DEFAULT' }, + scopes: [], +}; + export const usersWithIdStrategy = { name: ROLLOUT_STRATEGY_USER_ID, parameters: { userIds: '1,2,3' }, diff --git a/spec/frontend/sidebar/sidebar_labels_spec.js b/spec/frontend/sidebar/sidebar_labels_spec.js index 9d59dc750fb..7a687ffa761 100644 --- a/spec/frontend/sidebar/sidebar_labels_spec.js +++ b/spec/frontend/sidebar/sidebar_labels_spec.js @@ -1,6 +1,5 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; -import Vuex from 'vuex'; import { mockLabels, mockRegularLabel, @@ -9,17 +8,11 @@ import axios from '~/lib/utils/axios_utils'; import SidebarLabels from '~/sidebar/components/labels/sidebar_labels.vue'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; -import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; - -const localVue = createLocalVue(); -localVue.use(Vuex); describe('sidebar labels', () => { let axiosMock; let wrapper; - const store = new Vuex.Store(labelsSelectModule()); - const defaultProps = { allowLabelCreate: true, allowLabelEdit: true, @@ -39,11 +32,9 @@ describe('sidebar labels', () => { const mountComponent = () => { wrapper = shallowMount(SidebarLabels, { - localVue, provide: { ...defaultProps, }, - store, }); }; @@ -81,7 +72,7 @@ describe('sidebar labels', () => { }); }); - describe('when labels are changed', () => { + describe('when labels are updated', () => { beforeEach(() => { mountComponent(); }); @@ -121,4 +112,24 @@ describe('sidebar labels', () => { expect(axiosMock.history.put[0].data).toEqual(JSON.stringify(expected)); }); }); + + describe('when label `x` is clicked', () => { + beforeEach(() => { + mountComponent(); + }); + + it('makes an API call to update labels', async () => { + findLabelsSelect().vm.$emit('onLabelRemove', 27); + + await axios.waitForAll(); + + const expected = { + [defaultProps.issuableType]: { + label_ids: [26, 28, 29], + }, + }; + + expect(axiosMock.history.put[0].data).toEqual(JSON.stringify(expected)); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js index 6c0ba8afede..93d8e640968 100644 --- a/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js +++ b/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js @@ -22,6 +22,8 @@ describe('UserAvatar', () => { const getByText = (text, options) => createWrapper(within(wrapper.element).findByText(text, options)); + const findStatusEmoji = emoji => wrapper.find(`gl-emoji[data-name="${emoji}"]`); + afterEach(() => { wrapper.destroy(); }); @@ -82,4 +84,32 @@ describe('UserAvatar', () => { expect(getByText("It's you").exists()).toBe(true); }); }); + + describe('user status', () => { + const emoji = 'island'; + + describe('when set', () => { + it('displays the status emoji', () => { + createComponent({ + member: { + ...memberMock, + user: { + ...memberMock.user, + status: { emoji, messageHtml: 'On vacation' }, + }, + }, + }); + + expect(findStatusEmoji(emoji).exists()).toBe(true); + }); + }); + + describe('when not set', () => { + it('does not display status emoji', () => { + createComponent(); + + expect(findStatusEmoji(emoji).exists()).toBe(false); + }); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/members/mock_data.js b/spec/frontend/vue_shared/components/members/mock_data.js index 3195f04f202..d7bb8c0d142 100644 --- a/spec/frontend/vue_shared/components/members/mock_data.js +++ b/spec/frontend/vue_shared/components/members/mock_data.js @@ -24,6 +24,14 @@ export const member = { usingLicense: false, groupSso: false, groupManagedAccount: false, + validRoles: { + Guest: 10, + Reporter: 20, + Developer: 30, + Maintainer: 40, + Owner: 50, + 'Minimal Access': 5, + }, }; export const group = { @@ -39,6 +47,7 @@ export const group = { id: 3, createdAt: '2020-08-06T15:31:07.662Z', expiresAt: null, + validRoles: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }, }; const { user, ...memberNoUser } = member; diff --git a/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js b/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js index 139093d5a9c..ba693975a88 100644 --- a/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js +++ b/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js @@ -65,6 +65,14 @@ describe('MemberList', () => { const findWrappedComponent = () => wrapper.find(WrappedComponent); + const memberCurrentUser = { + ...memberMock, + user: { + ...memberMock.user, + id: 1, + }, + }; + const createComponentWithDirectMember = (member = {}) => { createComponent({ member: { @@ -115,18 +123,20 @@ describe('MemberList', () => { expect(findWrappedComponent().props('isDirectMember')).toBe(false); }); + + it('returns `true` for linked groups', () => { + createComponent({ + member: group, + }); + + expect(findWrappedComponent().props('isDirectMember')).toBe(true); + }); }); describe('isCurrentUser', () => { it('returns `true` when `member.user` has the same ID as `currentUserId`', () => { createComponent({ - member: { - ...memberMock, - user: { - ...memberMock.user, - id: 1, - }, - }, + member: memberCurrentUser, }); expect(findWrappedComponent().props('isCurrentUser')).toBe(true); @@ -203,5 +213,39 @@ describe('MemberList', () => { }); }); }); + + describe('canUpdate', () => { + describe('for a direct member', () => { + it('returns `true` when `canUpdate` is `true`', () => { + createComponentWithDirectMember({ + canUpdate: true, + }); + + expect(findWrappedComponent().props('permissions').canUpdate).toBe(true); + }); + + it('returns `false` when `canUpdate` is `false`', () => { + createComponentWithDirectMember({ + canUpdate: false, + }); + + expect(findWrappedComponent().props('permissions').canUpdate).toBe(false); + }); + + it('returns `false` for current user', () => { + createComponentWithDirectMember(memberCurrentUser); + + expect(findWrappedComponent().props('permissions').canUpdate).toBe(false); + }); + }); + + describe('for an inherited member', () => { + it('returns `false`', () => { + createComponentWithInheritedMember(); + + expect(findWrappedComponent().props('permissions').canUpdate).toBe(false); + }); + }); + }); }); }); diff --git a/spec/frontend/vue_shared/components/members/table/members_table_spec.js b/spec/frontend/vue_shared/components/members/table/members_table_spec.js index 567b0b18c6f..ec3b75b82ea 100644 --- a/spec/frontend/vue_shared/components/members/table/members_table_spec.js +++ b/spec/frontend/vue_shared/components/members/table/members_table_spec.js @@ -4,11 +4,13 @@ import { getByText as getByTextHelper, getByTestId as getByTestIdHelper, } from '@testing-library/dom'; +import { GlBadge } from '@gitlab/ui'; import MembersTable from '~/vue_shared/components/members/table/members_table.vue'; import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue'; import MemberSource from '~/vue_shared/components/members/table/member_source.vue'; import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue'; import CreatedAt from '~/vue_shared/components/members/table/created_at.vue'; +import RoleDropdown from '~/vue_shared/components/members/table/role_dropdown.vue'; import MemberActionButtons from '~/vue_shared/components/members/table/member_action_buttons.vue'; import * as initUserPopovers from '~/user_popovers'; import { member as memberMock, invite, accessRequest } from '../mock_data'; @@ -24,6 +26,7 @@ describe('MemberList', () => { state: { members: [], tableFields: [], + sourceId: 1, ...state, }, }); @@ -39,6 +42,7 @@ describe('MemberList', () => { 'expires-at', 'created-at', 'member-action-buttons', + 'role-dropdown', ], }); }; @@ -55,16 +59,22 @@ describe('MemberList', () => { }); describe('fields', () => { + const memberCanUpdate = { + ...memberMock, + canUpdate: true, + source: { ...memberMock.source, id: 1 }, + }; + it.each` - field | label | member | expectedComponent - ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar} - ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource} - ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt} - ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt} - ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt} - ${'expires'} | ${'Access expires'} | ${memberMock} | ${ExpiresAt} - ${'maxRole'} | ${'Max role'} | ${memberMock} | ${null} - ${'expiration'} | ${'Expiration'} | ${memberMock} | ${null} + field | label | member | expectedComponent + ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar} + ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource} + ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt} + ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt} + ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt} + ${'expires'} | ${'Access expires'} | ${memberMock} | ${ExpiresAt} + ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown} + ${'expiration'} | ${'Expiration'} | ${memberMock} | ${null} `('renders the $label field', ({ field, label, member, expectedComponent }) => { createComponent({ members: [member], @@ -107,6 +117,19 @@ describe('MemberList', () => { }); }); + describe('when member can not be updated', () => { + it('renders badge in "Max role" field', () => { + createComponent({ members: [memberMock], tableFields: ['maxRole'] }); + + expect( + wrapper + .find(`[data-label="Max role"][role="cell"]`) + .find(GlBadge) + .text(), + ).toBe(memberMock.accessLevel.stringValue); + }); + }); + it('initializes user popovers when mounted', () => { const initUserPopoversMock = jest.spyOn(initUserPopovers, 'default'); diff --git a/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js b/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js new file mode 100644 index 00000000000..5e5a013018b --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js @@ -0,0 +1,87 @@ +import { mount, createWrapper } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { within } from '@testing-library/dom'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +import RoleDropdown from '~/vue_shared/components/members/table/role_dropdown.vue'; +import { member } from '../mock_data'; + +describe('RoleDropdown', () => { + let wrapper; + + const createComponent = (propsData = {}) => { + wrapper = mount(RoleDropdown, { + propsData: { + member, + ...propsData, + }, + }); + }; + + const getDropdownMenu = () => within(wrapper.element).getByRole('menu'); + const getByTextInDropdownMenu = (text, options = {}) => + createWrapper(within(getDropdownMenu()).getByText(text, options)); + const getDropdownItemByText = text => + getByTextInDropdownMenu(text, { selector: '[role="menuitem"] p' }); + const getCheckedDropdownItem = () => + wrapper + .findAll(GlDropdownItem) + .wrappers.find(dropdownItemWrapper => dropdownItemWrapper.props('isChecked')); + + const findDropdownToggle = () => wrapper.find('button[aria-haspopup="true"]'); + const findDropdown = () => wrapper.find(GlDropdown); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when dropdown is open', () => { + beforeEach(done => { + createComponent(); + + findDropdownToggle().trigger('click'); + wrapper.vm.$root.$on('bv::dropdown::shown', () => { + done(); + }); + }); + + it('renders all valid roles', () => { + Object.keys(member.validRoles).forEach(role => { + expect(getDropdownItemByText(role).exists()).toBe(true); + }); + }); + + it('renders dropdown header', () => { + expect(getByTextInDropdownMenu('Change permissions').exists()).toBe(true); + }); + + it('sets dropdown toggle and checks selected role', async () => { + expect(findDropdownToggle().text()).toBe('Owner'); + expect(getCheckedDropdownItem().text()).toBe('Owner'); + }); + }); + + it("sets initial dropdown toggle value to member's role", () => { + createComponent(); + + expect(findDropdownToggle().text()).toBe('Owner'); + }); + + it('sets the dropdown alignment to right on mobile', async () => { + jest.spyOn(bp, 'isDesktop').mockReturnValue(false); + createComponent(); + + await nextTick(); + + expect(findDropdown().attributes('right')).toBe('true'); + }); + + it('sets the dropdown alignment to left on desktop', async () => { + jest.spyOn(bp, 'isDesktop').mockReturnValue(true); + createComponent(); + + await nextTick(); + + expect(findDropdown().attributes('right')).toBeUndefined(); + }); +}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js index bfb8e263d81..c742220ba8a 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js @@ -259,21 +259,6 @@ describe('LabelsSelect Actions', () => { }); }); - describe('replaceSelectedLabels', () => { - it('replaces `state.selectedLabels`', done => { - const selectedLabels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - - testAction( - actions.replaceSelectedLabels, - selectedLabels, - state, - [{ type: types.REPLACE_SELECTED_LABELS, payload: selectedLabels }], - [], - done, - ); - }); - }); - describe('updateSelectedLabels', () => { it('updates `state.labels` based on provided `labels` param', done => { const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js index 3414eec8a63..8081806e314 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js @@ -152,19 +152,6 @@ describe('LabelsSelect Mutations', () => { }); }); - describe(`${types.REPLACE_SELECTED_LABELS}`, () => { - it('replaces `state.selectedLabels`', () => { - const state = { - selectedLabels: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }], - }; - const newSelectedLabels = [{ id: 2 }, { id: 5 }]; - - mutations[types.REPLACE_SELECTED_LABELS](state, newSelectedLabels); - - expect(state.selectedLabels).toEqual(newSelectedLabels); - }); - }); - describe(`${types.UPDATE_SELECTED_LABELS}`, () => { const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; diff --git a/spec/graphql/types/ci/group_type_spec.rb b/spec/graphql/types/ci/group_type_spec.rb index 8d547b19af3..d7ce5602612 100644 --- a/spec/graphql/types/ci/group_type_spec.rb +++ b/spec/graphql/types/ci/group_type_spec.rb @@ -10,6 +10,7 @@ RSpec.describe Types::Ci::GroupType do name size jobs + detailedStatus ] expect(described_class).to have_graphql_fields(*expected_fields) diff --git a/spec/graphql/types/ci/job_type_spec.rb b/spec/graphql/types/ci/job_type_spec.rb index faf3a95cf25..32382bf21ed 100644 --- a/spec/graphql/types/ci/job_type_spec.rb +++ b/spec/graphql/types/ci/job_type_spec.rb @@ -9,6 +9,7 @@ RSpec.describe Types::Ci::JobType do expected_fields = %i[ name needs + detailedStatus ] expect(described_class).to have_graphql_fields(*expected_fields) diff --git a/spec/graphql/types/ci/stage_type_spec.rb b/spec/graphql/types/ci/stage_type_spec.rb index 0c352ed27aa..9a8d4fa96a3 100644 --- a/spec/graphql/types/ci/stage_type_spec.rb +++ b/spec/graphql/types/ci/stage_type_spec.rb @@ -9,6 +9,7 @@ RSpec.describe Types::Ci::StageType do expected_fields = %i[ name groups + detailedStatus ] expect(described_class).to have_graphql_fields(*expected_fields) diff --git a/spec/lib/backup/repositories_spec.rb b/spec/lib/backup/repositories_spec.rb index 5f734f4b71b..9c139e9f954 100644 --- a/spec/lib/backup/repositories_spec.rb +++ b/spec/lib/backup/repositories_spec.rb @@ -162,15 +162,17 @@ RSpec.describe Backup::Repositories do let_it_be(:personal_snippet) { create(:personal_snippet, author: project.owner) } let_it_be(:project_snippet) { create(:project_snippet, project: project, author: project.owner) } - it 'restores repositories from bundles', :aggregate_failures do - next_path_to_bundle = [ + let(:next_path_to_bundle) do + [ Rails.root.join('spec/fixtures/lib/backup/project_repo.bundle'), Rails.root.join('spec/fixtures/lib/backup/wiki_repo.bundle'), Rails.root.join('spec/fixtures/lib/backup/design_repo.bundle'), Rails.root.join('spec/fixtures/lib/backup/personal_snippet_repo.bundle'), Rails.root.join('spec/fixtures/lib/backup/project_snippet_repo.bundle') ].to_enum + end + it 'restores repositories from bundles', :aggregate_failures do allow_next_instance_of(described_class::BackupRestore) do |backup_restore| allow(backup_restore).to receive(:path_to_bundle).and_return(next_path_to_bundle.next) end @@ -231,6 +233,9 @@ RSpec.describe Backup::Repositories do end it 'cleans existing repositories' do + success_response = ServiceResponse.success(message: "Valid Snippet Repo") + allow(Snippets::RepositoryValidationService).to receive_message_chain(:new, :execute).and_return(success_response) + expect_next_instance_of(DesignManagement::Repository) do |repository| expect(repository).to receive(:remove) end @@ -246,5 +251,58 @@ RSpec.describe Backup::Repositories do subject.restore end + + context 'restoring snippets' do + before do + create(:snippet_repository, snippet: personal_snippet) + create(:snippet_repository, snippet: project_snippet) + + allow_next_instance_of(described_class::BackupRestore) do |backup_restore| + allow(backup_restore).to receive(:path_to_bundle).and_return(next_path_to_bundle.next) + end + end + + context 'when the repository is valid' do + it 'restores the snippet repositories' do + subject.restore + + expect(personal_snippet.snippet_repository.persisted?).to be true + expect(personal_snippet.repository).to exist + + expect(project_snippet.snippet_repository.persisted?).to be true + expect(project_snippet.repository).to exist + end + end + + context 'when repository is invalid' do + before do + error_response = ServiceResponse.error(message: "Repository has more than one branch") + allow(Snippets::RepositoryValidationService).to receive_message_chain(:new, :execute).and_return(error_response) + end + + it 'shows the appropriate error' do + subject.restore + + expect(progress).to have_received(:puts).with("Snippet #{personal_snippet.full_path} can't be restored: Repository has more than one branch") + expect(progress).to have_received(:puts).with("Snippet #{project_snippet.full_path} can't be restored: Repository has more than one branch") + end + + it 'removes the snippets from the DB' do + expect { subject.restore }.to change(PersonalSnippet, :count).by(-1) + .and change(ProjectSnippet, :count).by(-1) + .and change(SnippetRepository, :count).by(-2) + end + + it 'removes the repository from disk' do + gitlab_shell = Gitlab::Shell.new + shard_name = personal_snippet.repository.shard + path = personal_snippet.disk_path + '.git' + + subject.restore + + expect(gitlab_shell.repository_exists?(shard_name, path)).to eq false + end + end + end end end diff --git a/spec/lib/gitlab/ci/status/canceled_spec.rb b/spec/lib/gitlab/ci/status/canceled_spec.rb index a35efae5c57..7fae76f61ea 100644 --- a/spec/lib/gitlab/ci/status/canceled_spec.rb +++ b/spec/lib/gitlab/ci/status/canceled_spec.rb @@ -26,4 +26,8 @@ RSpec.describe Gitlab::Ci::Status::Canceled do describe '#group' do it { expect(subject.group).to eq 'canceled' } end + + describe '#details_path' do + it { expect(subject.details_path).to be_nil } + end end diff --git a/spec/lib/gitlab/ci/status/created_spec.rb b/spec/lib/gitlab/ci/status/created_spec.rb index 1ddced923f6..1e54d1ed8c5 100644 --- a/spec/lib/gitlab/ci/status/created_spec.rb +++ b/spec/lib/gitlab/ci/status/created_spec.rb @@ -26,4 +26,8 @@ RSpec.describe Gitlab::Ci::Status::Created do describe '#group' do it { expect(subject.group).to eq 'created' } end + + describe '#details_path' do + it { expect(subject.details_path).to be_nil } + end end diff --git a/spec/lib/gitlab/ci/status/failed_spec.rb b/spec/lib/gitlab/ci/status/failed_spec.rb index e8bd728b740..f3f3304b04d 100644 --- a/spec/lib/gitlab/ci/status/failed_spec.rb +++ b/spec/lib/gitlab/ci/status/failed_spec.rb @@ -26,4 +26,8 @@ RSpec.describe Gitlab::Ci::Status::Failed do describe '#group' do it { expect(subject.group).to eq 'failed' } end + + describe '#details_path' do + it { expect(subject.details_path).to be_nil } + end end diff --git a/spec/lib/gitlab/ci/status/pending_spec.rb b/spec/lib/gitlab/ci/status/pending_spec.rb index 0e47b19d9c1..1c062a0133d 100644 --- a/spec/lib/gitlab/ci/status/pending_spec.rb +++ b/spec/lib/gitlab/ci/status/pending_spec.rb @@ -26,4 +26,8 @@ RSpec.describe Gitlab::Ci::Status::Pending do describe '#group' do it { expect(subject.group).to eq 'pending' } end + + describe '#details_path' do + it { expect(subject.details_path).to be_nil } + end end diff --git a/spec/lib/gitlab/ci/status/preparing_spec.rb b/spec/lib/gitlab/ci/status/preparing_spec.rb index 6d33eb77560..ec1850c1959 100644 --- a/spec/lib/gitlab/ci/status/preparing_spec.rb +++ b/spec/lib/gitlab/ci/status/preparing_spec.rb @@ -26,4 +26,8 @@ RSpec.describe Gitlab::Ci::Status::Preparing do describe '#group' do it { expect(subject.group).to eq 'preparing' } end + + describe '#details_path' do + it { expect(subject.details_path).to be_nil } + end end diff --git a/spec/lib/gitlab/ci/status/running_spec.rb b/spec/lib/gitlab/ci/status/running_spec.rb index fbc7bfd81b3..e40d696ee4d 100644 --- a/spec/lib/gitlab/ci/status/running_spec.rb +++ b/spec/lib/gitlab/ci/status/running_spec.rb @@ -26,4 +26,8 @@ RSpec.describe Gitlab::Ci::Status::Running do describe '#group' do it { expect(subject.group).to eq 'running' } end + + describe '#details_path' do + it { expect(subject.details_path).to be_nil } + end end diff --git a/spec/lib/gitlab/ci/status/scheduled_spec.rb b/spec/lib/gitlab/ci/status/scheduled_spec.rb index 4a1dae937ca..8a923faf3f9 100644 --- a/spec/lib/gitlab/ci/status/scheduled_spec.rb +++ b/spec/lib/gitlab/ci/status/scheduled_spec.rb @@ -26,4 +26,8 @@ RSpec.describe Gitlab::Ci::Status::Scheduled do describe '#group' do it { expect(subject.group).to eq 'scheduled' } end + + describe '#details_path' do + it { expect(subject.details_path).to be_nil } + end end diff --git a/spec/lib/gitlab/ci/status/skipped_spec.rb b/spec/lib/gitlab/ci/status/skipped_spec.rb index f402bbe5221..ac3c2f253f7 100644 --- a/spec/lib/gitlab/ci/status/skipped_spec.rb +++ b/spec/lib/gitlab/ci/status/skipped_spec.rb @@ -26,4 +26,8 @@ RSpec.describe Gitlab::Ci::Status::Skipped do describe '#group' do it { expect(subject.group).to eq 'skipped' } end + + describe '#details_path' do + it { expect(subject.details_path).to be_nil } + end end diff --git a/spec/lib/gitlab/ci/status/success_spec.rb b/spec/lib/gitlab/ci/status/success_spec.rb index 2d1c50448d4..f2069334abd 100644 --- a/spec/lib/gitlab/ci/status/success_spec.rb +++ b/spec/lib/gitlab/ci/status/success_spec.rb @@ -26,4 +26,8 @@ RSpec.describe Gitlab::Ci::Status::Success do describe '#group' do it { expect(subject.group).to eq 'success' } end + + describe '#details_path' do + it { expect(subject.details_path).to be_nil } + end end diff --git a/spec/lib/gitlab/ci/status/waiting_for_resource_spec.rb b/spec/lib/gitlab/ci/status/waiting_for_resource_spec.rb index de18198c6c2..bb6139accaf 100644 --- a/spec/lib/gitlab/ci/status/waiting_for_resource_spec.rb +++ b/spec/lib/gitlab/ci/status/waiting_for_resource_spec.rb @@ -26,4 +26,8 @@ RSpec.describe Gitlab::Ci::Status::WaitingForResource do describe '#group' do it { expect(subject.group).to eq 'waiting-for-resource' } end + + describe '#details_path' do + it { expect(subject.details_path).to be_nil } + end end diff --git a/spec/lib/gitlab/ci/trace_spec.rb b/spec/lib/gitlab/ci/trace_spec.rb index cd67f913c9b..92bf2519588 100644 --- a/spec/lib/gitlab/ci/trace_spec.rb +++ b/spec/lib/gitlab/ci/trace_spec.rb @@ -33,6 +33,16 @@ RSpec.describe Gitlab::Ci::Trace, :clean_gitlab_redis_shared_state, factory_defa expect(artifact2.job.trace.raw).to eq(test_data) end + + it 'reloads the trace in case of a chunk error' do + chunk_error = described_class::ChunkedIO::FailedToGetChunkError + + allow_any_instance_of(described_class::Stream) + .to receive(:raw).and_raise(chunk_error) + + expect(build).to receive(:reset).and_return(build) + expect { trace.raw }.to raise_error(chunk_error) + end end context 'when live trace feature is disabled' do diff --git a/spec/lib/gitlab/git/pre_receive_error_spec.rb b/spec/lib/gitlab/git/pre_receive_error_spec.rb index 2ad27361c80..1a10ff56266 100644 --- a/spec/lib/gitlab/git/pre_receive_error_spec.rb +++ b/spec/lib/gitlab/git/pre_receive_error_spec.rb @@ -21,13 +21,21 @@ RSpec.describe Gitlab::Git::PreReceiveError do expect(ex.raw_message).to eq(raw_message) end - it 'sanitizes the user message' do - raw_message = 'Raw message' - ex = described_class.new(raw_message, "#{prefix} User message") + it 'prefers the original message over the fallback' do + raw_message = "#{prefix} Hello,\nworld!" + ex = described_class.new(raw_message, fallback_message: "User message") + expect(ex.message).to eq('Hello,') expect(ex.raw_message).to eq(raw_message) - expect(ex.message).to eq('User message') end end + + it 'uses the fallback message' do + raw_message = 'Hello\n' + ex = described_class.new(raw_message, fallback_message: "User message") + + expect(ex.raw_message).to eq(raw_message) + expect(ex.message).to eq('User message') + end end end diff --git a/spec/lib/gitlab/middleware/go_spec.rb b/spec/lib/gitlab/middleware/go_spec.rb index af6146ea93c..7bac041cd65 100644 --- a/spec/lib/gitlab/middleware/go_spec.rb +++ b/spec/lib/gitlab/middleware/go_spec.rb @@ -142,8 +142,8 @@ RSpec.describe Gitlab::Middleware::Go do response = go expect(response[0]).to eq(403) - expect(response[1]['Content-Length']).to eq('0') - expect(response[2].body).to eq(['']) + expect(response[1]['Content-Length']).to be_nil + expect(response[2]).to eq(['']) end end end @@ -187,10 +187,11 @@ RSpec.describe Gitlab::Middleware::Go do it 'returns 404' do response = go + expect(response[0]).to eq(404) expect(response[1]['Content-Type']).to eq('text/html') expected_body = %{<html><body>go get #{Gitlab.config.gitlab.url}/#{project.full_path}</body></html>} - expect(response[2].body).to eq([expected_body]) + expect(response[2]).to eq([expected_body]) end end @@ -262,7 +263,7 @@ RSpec.describe Gitlab::Middleware::Go do expect(response[0]).to eq(200) expect(response[1]['Content-Type']).to eq('text/html') expected_body = %{<html><head><meta name="go-import" content="#{Gitlab.config.gitlab.host}/#{path} git #{repository_url}" /><meta name="go-source" content="#{Gitlab.config.gitlab.host}/#{path} #{project_url} #{project_url}/-/tree/#{branch}{/dir} #{project_url}/-/blob/#{branch}{/dir}/{file}#L{line}" /></head><body>go get #{Gitlab.config.gitlab.url}/#{path}</body></html>} - expect(response[2].body).to eq([expected_body]) + expect(response[2]).to eq([expected_body]) end end end diff --git a/spec/lib/gitlab/middleware/same_site_cookies_spec.rb b/spec/lib/gitlab/middleware/same_site_cookies_spec.rb index 2d1a9b2eee2..18342fd78ac 100644 --- a/spec/lib/gitlab/middleware/same_site_cookies_spec.rb +++ b/spec/lib/gitlab/middleware/same_site_cookies_spec.rb @@ -60,12 +60,12 @@ RSpec.describe Gitlab::Middleware::SameSiteCookies do end context 'with no cookies' do - let(:cookies) { nil } + let(:cookies) { "" } it 'does not add headers' do response = do_request - expect(response['Set-Cookie']).to be_nil + expect(response['Set-Cookie']).to eq("") end end diff --git a/spec/models/ci/deleted_object_spec.rb b/spec/models/ci/deleted_object_spec.rb new file mode 100644 index 00000000000..cb8911d5027 --- /dev/null +++ b/spec/models/ci/deleted_object_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::DeletedObject, :aggregate_failures do + describe 'attributes' do + it { is_expected.to respond_to(:file) } + it { is_expected.to respond_to(:store_dir) } + it { is_expected.to respond_to(:file_store) } + it { is_expected.to respond_to(:pick_up_at) } + end + + describe '.bulk_import' do + context 'with data' do + let!(:artifact) { create(:ci_job_artifact, :archive, :expired) } + + it 'imports data' do + expect { described_class.bulk_import(Ci::JobArtifact.all) }.to change { described_class.count }.by(1) + + deleted_artifact = described_class.first + + expect(deleted_artifact.file_store).to eq(artifact.file_store) + expect(deleted_artifact.store_dir).to eq(artifact.file.store_dir.to_s) + expect(deleted_artifact.file_identifier).to eq(artifact.file_identifier) + expect(deleted_artifact.pick_up_at).to eq(artifact.expire_at) + end + end + + context 'with invalid data' do + let!(:artifact) { create(:ci_job_artifact) } + + it 'does not import anything' do + expect(artifact.file_identifier).to be_nil + + expect { described_class.bulk_import([artifact]) } + .not_to change { described_class.count } + end + end + + context 'with empty data' do + it 'returns successfully' do + expect { described_class.bulk_import([]) } + .not_to change { described_class.count } + end + end + end + + context 'ActiveRecord scopes' do + let_it_be(:not_ready) { create(:ci_deleted_object, pick_up_at: 1.day.from_now) } + let_it_be(:ready) { create(:ci_deleted_object, pick_up_at: 1.day.ago) } + + describe '.ready_for_destruction' do + it 'returns objects that are ready' do + result = described_class.ready_for_destruction(2) + + expect(result).to contain_exactly(ready) + end + end + + describe '.lock_for_destruction' do + subject(:result) { described_class.lock_for_destruction(10) } + + it 'returns objects that are ready' do + expect(result).to contain_exactly(ready) + end + + it 'selects only the id' do + expect(result.select_values).to contain_exactly(:id) + end + + it 'orders by pick_up_at' do + expect(result.order_values.map(&:to_sql)) + .to contain_exactly("\"ci_deleted_objects\".\"pick_up_at\" ASC") + end + + it 'applies limit' do + expect(result.limit_value).to eq(10) + end + + it 'uses select for update' do + expect(result.locked?).to eq('FOR UPDATE SKIP LOCKED') + end + end + end + + describe '#delete_file_from_storage' do + let(:object) { build(:ci_deleted_object) } + + it 'does not raise errors' do + expect(object.file).to receive(:remove!).and_raise(StandardError) + + expect(object.delete_file_from_storage).to be_falsy + end + end +end diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index d33ccf0e6f2..88d08f1ec45 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -2988,6 +2988,57 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end end + describe '#builds_in_self_and_descendants' do + subject(:builds) { pipeline.builds_in_self_and_descendants } + + let(:pipeline) { create(:ci_pipeline, project: project) } + let!(:build) { create(:ci_build, pipeline: pipeline) } + + context 'when pipeline is standalone' do + it 'returns the list of builds' do + expect(builds).to contain_exactly(build) + end + end + + context 'when pipeline is parent of another pipeline' do + let(:child_pipeline) { create(:ci_pipeline, child_of: pipeline) } + let!(:child_build) { create(:ci_build, pipeline: child_pipeline) } + + it 'returns the list of builds' do + expect(builds).to contain_exactly(build, child_build) + end + end + + context 'when pipeline is parent of another parent pipeline' do + let(:child_pipeline) { create(:ci_pipeline, child_of: pipeline) } + let!(:child_build) { create(:ci_build, pipeline: child_pipeline) } + let(:child_of_child_pipeline) { create(:ci_pipeline, child_of: child_pipeline) } + let!(:child_of_child_build) { create(:ci_build, pipeline: child_of_child_pipeline) } + + it 'returns the list of builds' do + expect(builds).to contain_exactly(build, child_build, child_of_child_build) + end + end + end + + describe '#build_with_artifacts_in_self_and_descendants' do + let!(:build) { create(:ci_build, name: 'test', pipeline: pipeline) } + let(:child_pipeline) { create(:ci_pipeline, child_of: pipeline) } + let!(:child_build) { create(:ci_build, :artifacts, name: 'test', pipeline: child_pipeline) } + + it 'returns the build with a given name, having artifacts' do + expect(pipeline.build_with_artifacts_in_self_and_descendants('test')).to eq(child_build) + end + + context 'when same job name is present in both parent and child pipeline' do + let!(:build) { create(:ci_build, :artifacts, name: 'test', pipeline: pipeline) } + + it 'returns the job in the parent pipeline' do + expect(pipeline.build_with_artifacts_in_self_and_descendants('test')).to eq(build) + end + end + end + describe '#find_job_with_archive_artifacts' do let!(:old_job) { create(:ci_build, name: 'rspec', retried: true, pipeline: pipeline) } let!(:job_without_artifacts) { create(:ci_build, name: 'rspec', pipeline: pipeline) } diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index ed027d02b5b..6c5c690a54f 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -222,6 +222,36 @@ RSpec.describe Group do end end end + + describe '#two_factor_authentication_allowed' do + let_it_be(:group) { create(:group) } + + context 'for a parent group' do + it 'is valid' do + group.require_two_factor_authentication = true + + expect(group).to be_valid + end + end + + context 'for a child group' do + let(:sub_group) { create(:group, parent: group) } + + it 'is valid when parent group allows' do + sub_group.require_two_factor_authentication = true + + expect(sub_group).to be_valid + end + + it 'is invalid when parent group blocks' do + group.namespace_settings.update!(allow_mfa_for_subgroups: false) + sub_group.require_two_factor_authentication = true + + expect(sub_group).to be_invalid + expect(sub_group.errors[:require_two_factor_authentication]).to include('is forbidden by a top-level group') + end + end + end end describe '.without_integration' do diff --git a/spec/models/namespace_setting_spec.rb b/spec/models/namespace_setting_spec.rb index cee0138e41d..c6e8d5b129c 100644 --- a/spec/models/namespace_setting_spec.rb +++ b/spec/models/namespace_setting_spec.rb @@ -5,7 +5,9 @@ require 'spec_helper' RSpec.describe NamespaceSetting, type: :model do # Relationships # - it { is_expected.to belong_to(:namespace) } + describe "Associations" do + it { is_expected.to belong_to(:namespace) } + end describe "validations" do describe "#default_branch_name_content" do @@ -43,5 +45,29 @@ RSpec.describe NamespaceSetting, type: :model do end end end + + describe '#allow_mfa_for_group' do + let(:settings) { group.namespace_settings } + + context 'group is top-level group' do + let(:group) { create(:group) } + + it 'is valid' do + settings.allow_mfa_for_subgroups = false + + expect(settings).to be_valid + end + end + + context 'group is a subgroup' do + let(:group) { create(:group, parent: create(:group)) } + + it 'is invalid' do + settings.allow_mfa_for_subgroups = false + + expect(settings).to be_invalid + end + end + end end end diff --git a/spec/requests/api/jobs_spec.rb b/spec/requests/api/jobs_spec.rb index 2d57146fbc9..c1498e03f76 100644 --- a/spec/requests/api/jobs_spec.rb +++ b/spec/requests/api/jobs_spec.rb @@ -465,12 +465,14 @@ RSpec.describe API::Jobs do end context 'find proper job' do + let(:job_with_artifacts) { job } + shared_examples 'a valid file' do context 'when artifacts are stored locally', :sidekiq_might_not_need_inline do let(:download_headers) do { 'Content-Transfer-Encoding' => 'binary', 'Content-Disposition' => - %Q(attachment; filename="#{job.artifacts_file.filename}"; filename*=UTF-8''#{job.artifacts_file.filename}) } + %Q(attachment; filename="#{job_with_artifacts.artifacts_file.filename}"; filename*=UTF-8''#{job.artifacts_file.filename}) } end it { expect(response).to have_gitlab_http_status(:ok) } @@ -518,6 +520,18 @@ RSpec.describe API::Jobs do it_behaves_like 'a valid file' end + + context 'with job name in a child pipeline' do + let(:child_pipeline) { create(:ci_pipeline, child_of: pipeline) } + let!(:child_job) { create(:ci_build, :artifacts, :success, name: 'rspec', pipeline: child_pipeline) } + let(:job_with_artifacts) { child_job } + + before do + get_for_ref('master', child_job.name) + end + + it_behaves_like 'a valid file' + end end end diff --git a/spec/services/ci/delete_objects_service_spec.rb b/spec/services/ci/delete_objects_service_spec.rb new file mode 100644 index 00000000000..448f8979681 --- /dev/null +++ b/spec/services/ci/delete_objects_service_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::DeleteObjectsService, :aggregate_failure do + let(:service) { described_class.new } + let(:artifact) { create(:ci_job_artifact, :archive) } + let(:data) { [artifact] } + + describe '#execute' do + before do + Ci::DeletedObject.bulk_import(data) + # We disable the check because the specs are wrapped in a transaction + allow(service).to receive(:transaction_open?).and_return(false) + end + + subject(:execute) { service.execute } + + it 'deletes records' do + expect { execute }.to change { Ci::DeletedObject.count }.by(-1) + end + + it 'deletes files' do + expect { execute }.to change { artifact.file.exists? } + end + + context 'when trying to execute without records' do + let(:data) { [] } + + it 'does not change the number of objects' do + expect { execute }.not_to change { Ci::DeletedObject.count } + end + end + + context 'when trying to remove the same file multiple times' do + let(:objects) { Ci::DeletedObject.all.to_a } + + before do + expect(service).to receive(:load_next_batch).twice.and_return(objects) + end + + it 'executes successfully' do + 2.times { expect(service.execute).to be_truthy } + end + end + + context 'with artifacts both ready and not ready for deletion' do + let(:data) { [] } + + let_it_be(:past_ready) { create(:ci_deleted_object, pick_up_at: 2.days.ago) } + let_it_be(:ready) { create(:ci_deleted_object, pick_up_at: 1.day.ago) } + + it 'skips records with pick_up_at in the future' do + not_ready = create(:ci_deleted_object, pick_up_at: 1.day.from_now) + + expect { execute }.to change { Ci::DeletedObject.count }.from(3).to(1) + expect(not_ready.reload.present?).to be_truthy + end + + it 'limits the number of records removed' do + stub_const("#{described_class}::BATCH_SIZE", 1) + + expect { execute }.to change { Ci::DeletedObject.count }.by(-1) + end + + it 'removes records in order' do + stub_const("#{described_class}::BATCH_SIZE", 1) + + execute + + expect { past_ready.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect(ready.reload.present?).to be_truthy + end + + it 'updates pick_up_at timestamp' do + allow(service).to receive(:destroy_everything) + + execute + + expect(past_ready.reload.pick_up_at).to be_like_time(10.minutes.from_now) + end + + it 'does not delete objects for which file deletion has failed' do + expect(past_ready) + .to receive(:delete_file_from_storage) + .and_return(false) + + expect(service) + .to receive(:load_next_batch) + .and_return([past_ready, ready]) + + expect { execute }.to change { Ci::DeletedObject.count }.from(2).to(1) + expect(past_ready.reload.present?).to be_truthy + end + end + + context 'with an open database transaction' do + it 'raises an exception and does not remove records' do + expect(service).to receive(:transaction_open?).and_return(true) + + expect { execute } + .to raise_error(Ci::DeleteObjectsService::TransactionInProgressError) + .and change { Ci::DeletedObject.count }.by(0) + end + end + end + + describe '#remaining_batches_count' do + subject { service.remaining_batches_count(max_batch_count: 3) } + + context 'when there is less than one batch size' do + before do + Ci::DeletedObject.bulk_import(data) + end + + it { is_expected.to eq(1) } + end + + context 'when there is more than one batch size' do + before do + objects_scope = double + + expect(Ci::DeletedObject) + .to receive(:ready_for_destruction) + .and_return(objects_scope) + + expect(objects_scope).to receive(:size).and_return(110) + end + + it { is_expected.to eq(2) } + end + end +end diff --git a/spec/services/merge_requests/ff_merge_service_spec.rb b/spec/services/merge_requests/ff_merge_service_spec.rb index 64c473d947f..aec5a3b3fa3 100644 --- a/spec/services/merge_requests/ff_merge_service_spec.rb +++ b/spec/services/merge_requests/ff_merge_service_spec.rb @@ -114,7 +114,7 @@ RSpec.describe MergeRequests::FfMergeService do error_message = 'error message' raw_message = 'The truth is out there' - pre_receive_error = Gitlab::Git::PreReceiveError.new(raw_message, "GitLab: #{error_message}") + pre_receive_error = Gitlab::Git::PreReceiveError.new(raw_message, fallback_message: error_message) allow(service).to receive(:repository).and_raise(pre_receive_error) allow(service).to receive(:execute_hooks) expect(Gitlab::ErrorTracking).to receive(:track_exception).with( diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb index 27c832d88c6..a520b59a2f9 100644 --- a/spec/support/shared_contexts/navbar_structure_context.rb +++ b/spec/support/shared_contexts/navbar_structure_context.rb @@ -72,6 +72,7 @@ RSpec.shared_context 'project navbar structure' do _('Serverless'), _('Kubernetes'), _('Environments'), + _('Feature Flags'), _('Product Analytics') ] }, diff --git a/spec/support/shared_examples/models/project_latest_successful_build_for_shared_examples.rb b/spec/support/shared_examples/models/project_latest_successful_build_for_shared_examples.rb index 7701ab42007..66cd8d1df12 100644 --- a/spec/support/shared_examples/models/project_latest_successful_build_for_shared_examples.rb +++ b/spec/support/shared_examples/models/project_latest_successful_build_for_shared_examples.rb @@ -60,4 +60,20 @@ RSpec.shared_examples 'latest successful build for sha or ref' do expect(subject).to be_nil end end + + context 'with build belonging to a child pipeline' do + let(:child_pipeline) { create_pipeline(project) } + let(:parent_bridge) { create(:ci_bridge, pipeline: pipeline, project: pipeline.project) } + let!(:pipeline_source) { create(:ci_sources_pipeline, source_job: parent_bridge, pipeline: child_pipeline)} + let!(:child_build) { create_build(child_pipeline, 'child-build') } + let(:build_name) { child_build.name } + + before do + child_pipeline.update!(source: :parent_pipeline) + end + + it 'returns the child build' do + expect(subject).to eq(child_build) + end + end end diff --git a/spec/tasks/gitlab/db_rake_spec.rb b/spec/tasks/gitlab/db_rake_spec.rb index e4630aefb85..c43cc5bc6ee 100644 --- a/spec/tasks/gitlab/db_rake_spec.rb +++ b/spec/tasks/gitlab/db_rake_spec.rb @@ -98,6 +98,29 @@ RSpec.describe 'gitlab:db namespace rake task' do end end + describe 'unattended' do + using RSpec::Parameterized::TableSyntax + + where(:schema_migration_table_exists, :needs_migrations, :rake_output) do + false | false | "unattended_migrations_completed" + false | true | "unattended_migrations_completed" + true | false | "unattended_migrations_static" + true | true | "unattended_migrations_completed" + end + + before do + allow(Rake::Task['gitlab:db:configure']).to receive(:invoke).and_return(true) + end + + with_them do + it 'outputs changed message for automation after operations happen' do + allow(ActiveRecord::Base.connection.schema_migration).to receive(:table_exists?).and_return(schema_migration_table_exists) + allow_any_instance_of(ActiveRecord::MigrationContext).to receive(:needs_migration?).and_return(needs_migrations) + expect { run_rake_task('gitlab:db:unattended') }. to output(/^#{rake_output}$/).to_stdout + end + end + end + describe 'clean_structure_sql' do let_it_be(:clean_rake_task) { 'gitlab:db:clean_structure_sql' } let_it_be(:test_task_name) { 'gitlab:db:_test_multiple_structure_cleans' } diff --git a/spec/workers/ci/delete_objects_worker_spec.rb b/spec/workers/ci/delete_objects_worker_spec.rb new file mode 100644 index 00000000000..6cb8e0cba37 --- /dev/null +++ b/spec/workers/ci/delete_objects_worker_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::DeleteObjectsWorker do + let(:worker) { described_class.new } + + it { expect(described_class.idempotent?).to be_truthy } + + describe '#perform' do + it 'executes a service' do + expect_next_instance_of(Ci::DeleteObjectsService) do |instance| + expect(instance).to receive(:execute) + expect(instance).to receive(:remaining_batches_count).once.and_call_original + end + + worker.perform + end + end + + describe '#max_running_jobs' do + using RSpec::Parameterized::TableSyntax + + before do + stub_feature_flags( + ci_delete_objects_low_concurrency: low, + ci_delete_objects_medium_concurrency: medium, + ci_delete_objects_high_concurrency: high + ) + end + + subject(:max_running_jobs) { worker.max_running_jobs } + + where(:low, :medium, :high, :expected) do + false | false | false | 0 + true | true | true | 2 + true | false | false | 2 + false | true | false | 20 + false | true | true | 20 + false | false | true | 50 + end + + with_them do + it 'sets up concurrency depending on the feature flag' do + expect(max_running_jobs).to eq(expected) + end + end + end +end diff --git a/spec/workers/ci/schedule_delete_objects_cron_worker_spec.rb b/spec/workers/ci/schedule_delete_objects_cron_worker_spec.rb new file mode 100644 index 00000000000..142df271f90 --- /dev/null +++ b/spec/workers/ci/schedule_delete_objects_cron_worker_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::ScheduleDeleteObjectsCronWorker do + let(:worker) { described_class.new } + + describe '#perform' do + it 'enqueues DeleteObjectsWorker jobs' do + expect(Ci::DeleteObjectsWorker).to receive(:perform_with_capacity) + + worker.perform + end + end +end |