summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-10-13 15:08:53 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-10-13 15:08:53 +0000
commit16515bdfcb89ccb28e6eb81020d1646dfa7c6fa4 (patch)
treeca7676376780d0c476b345f36827a61a4f6d84d3
parent6e91fbf77476011a7fd86ca3467aad6d7b110ff3 (diff)
downloadgitlab-ce-16515bdfcb89ccb28e6eb81020d1646dfa7c6fa4.tar.gz
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock4
-rw-r--r--app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue121
-rw-r--r--app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue2
-rw-r--r--app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue13
-rw-r--r--app/assets/javascripts/feature_flags/components/strategy.vue155
-rw-r--r--app/assets/javascripts/feature_flags/components/strategy_parameters.vue3
-rw-r--r--app/assets/javascripts/feature_flags/constants.js5
-rw-r--r--app/assets/javascripts/feature_flags/utils.js18
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue12
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue17
-rw-r--r--app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue49
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js6
-rw-r--r--app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/members/constants.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/members_table.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue49
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue17
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js1
-rw-r--r--app/assets/stylesheets/pages/members.scss4
-rw-r--r--app/controllers/admin/users_controller.rb2
-rw-r--r--app/controllers/projects/merge_requests/drafts_controller.rb2
-rw-r--r--app/graphql/types/ci/detailed_status_type.rb18
-rw-r--r--app/graphql/types/ci/group_type.rb3
-rw-r--r--app/graphql/types/ci/job_type.rb3
-rw-r--r--app/graphql/types/ci/stage_type.rb3
-rw-r--r--app/helpers/projects_helper.rb13
-rw-r--r--app/models/ci/deleted_object.rb37
-rw-r--r--app/models/ci/job_artifact.rb9
-rw-r--r--app/models/ci/pipeline.rb19
-rw-r--r--app/models/commit_status.rb1
-rw-r--r--app/models/group.rb11
-rw-r--r--app/models/namespace_setting.rb7
-rw-r--r--app/models/project.rb4
-rw-r--r--app/models/project_services/confluence_service.rb2
-rw-r--r--app/models/project_services/packagist_service.rb2
-rw-r--r--app/services/ci/delete_objects_service.rb62
-rw-r--r--app/uploaders/deleted_object_uploader.rb11
-rw-r--r--app/views/admin/abuse_reports/_abuse_report.html.haml4
-rw-r--r--app/views/admin/applications/_form.html.haml4
-rw-r--r--app/views/admin/applications/index.html.haml4
-rw-r--r--app/views/admin/applications/show.html.haml6
-rw-r--r--app/views/admin/groups/_form.html.haml8
-rw-r--r--app/views/admin/groups/index.html.haml2
-rw-r--r--app/views/admin/groups/show.html.haml2
-rw-r--r--app/views/admin/health_check/show.html.haml2
-rw-r--r--app/views/admin/identities/_form.html.haml2
-rw-r--r--app/views/admin/identities/_identity.html.haml4
-rw-r--r--app/views/admin/identities/index.html.haml2
-rw-r--r--app/views/admin/projects/index.html.haml4
-rw-r--r--app/views/admin/projects/show.html.haml4
-rw-r--r--app/views/admin/runners/_runner.html.haml8
-rw-r--r--app/views/admin/runners/index.html.haml10
-rw-r--r--app/views/admin/runners/show.html.haml4
-rw-r--r--app/views/admin/serverless/domains/_form.html.haml6
-rw-r--r--app/views/admin/sessions/_new_base.html.haml2
-rw-r--r--app/views/admin/sessions/_two_factor_otp.html.haml2
-rw-r--r--app/views/admin/spam_logs/_spam_log.html.haml6
-rw-r--r--app/views/admin/users/_form.html.haml2
-rw-r--r--app/views/jira_connect/subscriptions/index.html.haml9
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml6
-rw-r--r--app/views/shared/notes/_notes_with_form.html.haml2
-rw-r--r--app/workers/all_queues.yml16
-rw-r--r--app/workers/ci/delete_objects_worker.rb38
-rw-r--r--app/workers/ci/schedule_delete_objects_cron_worker.rb18
-rw-r--r--changelogs/unreleased/198-add-automation-friendly-migration-rake-tasks.yml5
-rw-r--r--changelogs/unreleased/216881-add-close-button-to-sidebar-labels-to-remove.yml5
-rw-r--r--changelogs/unreleased/263484-integration-descriptions-should-be-less-project-level-specific.yml5
-rw-r--r--changelogs/unreleased/263509_add_cross_site_cookies_browser_limitaion_message.yml5
-rw-r--r--changelogs/unreleased/264790-bs4-optimization-commit-2.yml5
-rw-r--r--changelogs/unreleased/add-ci-deleted-objects-table.yml5
-rw-r--r--changelogs/unreleased/feature-flags-flexible-rollout-ux.yml5
-rw-r--r--changelogs/unreleased/latest-successful-build-including-child-pipelines.yml5
-rw-r--r--changelogs/unreleased/lm-add-status-graphql.yml5
-rw-r--r--changelogs/unreleased/move-ff-menu-doc-to-core.yml5
-rw-r--r--changelogs/unreleased/mw-project-settings-icon-replacements.yml5
-rw-r--r--changelogs/unreleased/sh-improve-pre-receive-error-ff-merge-message.yml5
-rw-r--r--changelogs/unreleased/sh-update-rack-2-1-4.yml5
-rw-r--r--config/feature_flags/development/ci_delete_objects_high_concurrency.yml7
-rw-r--r--config/feature_flags/development/ci_delete_objects_low_concurrency.yml7
-rw-r--r--config/feature_flags/development/ci_delete_objects_medium_concurrency.yml7
-rw-r--r--config/gitlab.yml.example3
-rw-r--r--config/initializers/1_settings.rb3
-rw-r--r--config/routes/group.rb2
-rw-r--r--config/sidekiq_queues.yml2
-rw-r--r--db/migrate/20200813135558_create_ci_deleted_objects.rb29
-rw-r--r--db/schema_migrations/202008131355581
-rw-r--r--db/structure.sql25
-rw-r--r--doc/api/feature_flags.md6
-rw-r--r--doc/api/feature_flags_legacy.md6
-rw-r--r--doc/api/graphql/reference/gitlab_schema.graphql33
-rw-r--r--doc/api/graphql/reference/gitlab_schema.json68
-rw-r--r--doc/api/graphql/reference/index.md19
-rw-r--r--doc/api/job_artifacts.md10
-rw-r--r--doc/ci/pipelines/job_artifacts.md5
-rw-r--r--doc/development/documentation/index.md4
-rw-r--r--doc/install/README.md10
-rw-r--r--doc/integration/jira_development_panel.md2
-rw-r--r--doc/operations/feature_flags.md37
-rw-r--r--doc/user/project/integrations/overview.md2
-rw-r--r--doc/user/project/labels.md23
-rw-r--r--lib/api/ci/runner.rb3
-rw-r--r--lib/api/helpers.rb2
-rw-r--r--lib/api/internal/lfs.rb2
-rw-r--r--lib/backup/repositories.rb30
-rw-r--r--lib/gitlab/ci/status/canceled.rb4
-rw-r--r--lib/gitlab/ci/status/created.rb4
-rw-r--r--lib/gitlab/ci/status/failed.rb4
-rw-r--r--lib/gitlab/ci/status/manual.rb4
-rw-r--r--lib/gitlab/ci/status/pending.rb4
-rw-r--r--lib/gitlab/ci/status/preparing.rb4
-rw-r--r--lib/gitlab/ci/status/running.rb4
-rw-r--r--lib/gitlab/ci/status/scheduled.rb4
-rw-r--r--lib/gitlab/ci/status/skipped.rb4
-rw-r--r--lib/gitlab/ci/status/success.rb4
-rw-r--r--lib/gitlab/ci/status/waiting_for_resource.rb4
-rw-r--r--lib/gitlab/ci/trace.rb6
-rw-r--r--lib/gitlab/git/pre_receive_error.rb10
-rw-r--r--lib/gitlab/gitaly_client/operation_service.rb2
-rw-r--r--lib/tasks/gitlab/db.rake13
-rw-r--r--locale/gitlab.pot69
-rw-r--r--spec/controllers/admin/users_controller_spec.rb21
-rw-r--r--spec/controllers/groups/group_links_controller_spec.rb21
-rw-r--r--spec/factories/ci/deleted_object.rb9
-rw-r--r--spec/factories/ci/pipelines.rb16
-rw-r--r--spec/features/issues/user_edits_issue_spec.rb38
-rw-r--r--spec/features/projects/navbar_spec.rb8
-rw-r--r--spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js116
-rw-r--r--spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js13
-rw-r--r--spec/frontend/feature_flags/components/strategy_spec.js24
-rw-r--r--spec/frontend/feature_flags/mock_data.js25
-rw-r--r--spec/frontend/sidebar/sidebar_labels_spec.js33
-rw-r--r--spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js30
-rw-r--r--spec/frontend/vue_shared/components/members/mock_data.js9
-rw-r--r--spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js58
-rw-r--r--spec/frontend/vue_shared/components/members/table/members_table_spec.js41
-rw-r--r--spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js87
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js15
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js13
-rw-r--r--spec/graphql/types/ci/group_type_spec.rb1
-rw-r--r--spec/graphql/types/ci/job_type_spec.rb1
-rw-r--r--spec/graphql/types/ci/stage_type_spec.rb1
-rw-r--r--spec/lib/backup/repositories_spec.rb62
-rw-r--r--spec/lib/gitlab/ci/status/canceled_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/status/created_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/status/failed_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/status/pending_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/status/preparing_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/status/running_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/status/scheduled_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/status/skipped_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/status/success_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/status/waiting_for_resource_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/trace_spec.rb10
-rw-r--r--spec/lib/gitlab/git/pre_receive_error_spec.rb16
-rw-r--r--spec/lib/gitlab/middleware/go_spec.rb9
-rw-r--r--spec/lib/gitlab/middleware/same_site_cookies_spec.rb4
-rw-r--r--spec/models/ci/deleted_object_spec.rb95
-rw-r--r--spec/models/ci/pipeline_spec.rb51
-rw-r--r--spec/models/group_spec.rb30
-rw-r--r--spec/models/namespace_setting_spec.rb28
-rw-r--r--spec/requests/api/jobs_spec.rb16
-rw-r--r--spec/services/ci/delete_objects_service_spec.rb133
-rw-r--r--spec/services/merge_requests/ff_merge_service_spec.rb2
-rw-r--r--spec/support/shared_contexts/navbar_structure_context.rb1
-rw-r--r--spec/support/shared_examples/models/project_latest_successful_build_for_shared_examples.rb16
-rw-r--r--spec/tasks/gitlab/db_rake_spec.rb23
-rw-r--r--spec/workers/ci/delete_objects_worker_spec.rb49
-rw-r--r--spec/workers/ci/schedule_delete_objects_cron_worker_spec.rb15
174 files changed, 2306 insertions, 347 deletions
diff --git a/Gemfile b/Gemfile
index 89c04e17ed9..3110bee4979 100644
--- a/Gemfile
+++ b/Gemfile
@@ -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