summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-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
68 files changed, 720 insertions, 190 deletions
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