Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-10-02 12:09:03 +00:00
parent 251d3d2b23
commit 6092dcc437
171 changed files with 1538 additions and 1247 deletions

View file

@ -18,11 +18,6 @@ export default {
type: Object,
required: true,
},
diffFile: {
type: Object,
required: false,
default: () => ({}),
},
line: {
type: Object,
required: false,

View file

@ -1,114 +1,43 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlButton, GlLoadingIcon, GlIcon } from '@gitlab/ui';
import { sprintf, n__ } from '~/locale';
import DraftsCount from './drafts_count.vue';
import PublishButton from './publish_button.vue';
import { mapActions, mapGetters } from 'vuex';
import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
import PreviewItem from './preview_item.vue';
export default {
components: {
GlButton,
GlLoadingIcon,
GlDropdown,
GlDropdownItem,
GlIcon,
DraftsCount,
PublishButton,
PreviewItem,
},
computed: {
...mapGetters(['isNotesFetched']),
...mapGetters('batchComments', ['draftsCount', 'sortedDrafts']),
...mapState('batchComments', ['showPreviewDropdown']),
dropdownTitle() {
return sprintf(
n__('%{count} pending comment', '%{count} pending comments', this.draftsCount),
{ count: this.draftsCount },
);
},
},
watch: {
showPreviewDropdown() {
if (this.showPreviewDropdown && this.$refs.dropdown) {
this.$nextTick(() => this.$refs.dropdown.$el.focus());
}
},
},
mounted() {
document.addEventListener('click', this.onClickDocument);
},
beforeDestroy() {
document.removeEventListener('click', this.onClickDocument);
},
methods: {
...mapActions('batchComments', ['toggleReviewDropdown']),
...mapActions('batchComments', ['scrollToDraft']),
isLast(index) {
return index === this.sortedDrafts.length - 1;
},
onClickDocument({ target }) {
if (
this.showPreviewDropdown &&
!target.closest('.review-preview-dropdown, .js-publish-draft-button')
) {
this.toggleReviewDropdown();
}
},
},
};
</script>
<template>
<div
class="dropdown float-right review-preview-dropdown"
:class="{
show: showPreviewDropdown,
}"
<gl-dropdown
:header-text="n__('%d pending comment', '%d pending comments', draftsCount)"
dropup
toggle-class="qa-review-preview-toggle"
>
<gl-button
ref="dropdown"
type="button"
category="primary"
variant="success"
class="review-preview-dropdown-toggle qa-review-preview-toggle"
@click="toggleReviewDropdown"
<template #button-content>
{{ __('Pending comments') }}
<gl-icon class="dropdown-chevron" name="chevron-up" />
</template>
<gl-dropdown-item
v-for="(draft, index) in sortedDrafts"
:key="draft.id"
@click="scrollToDraft(draft)"
>
{{ __('Finish review') }}
<drafts-count />
<gl-icon name="angle-up" />
</gl-button>
<div
class="dropdown-menu dropdown-menu-large dropdown-menu-right dropdown-open-top"
:class="{
show: showPreviewDropdown,
}"
>
<div class="dropdown-title gl-display-flex gl-align-items-center">
<span class="gl-ml-auto">{{ dropdownTitle }}</span>
<gl-button
:aria-label="__('Close')"
type="button"
category="tertiary"
size="small"
class="dropdown-title-button gl-ml-auto gl-p-0!"
icon="close"
@click="toggleReviewDropdown"
/>
</div>
<div class="dropdown-content">
<ul v-if="isNotesFetched">
<li v-for="(draft, index) in sortedDrafts" :key="draft.id">
<preview-item :draft="draft" :is-last="isLast(index)" />
</li>
</ul>
<gl-loading-icon v-else size="lg" class="gl-mt-3 gl-mb-3" />
</div>
<div class="dropdown-footer">
<publish-button
:show-count="false"
:should-publish="true"
:label="__('Submit review')"
class="float-right gl-mr-3"
/>
</div>
</div>
</div>
<preview-item :draft="draft" :is-last="isLast(index)" />
</gl-dropdown-item>
</gl-dropdown>
</template>

View file

@ -1,5 +1,5 @@
<script>
import { mapActions, mapGetters } from 'vuex';
import { mapGetters } from 'vuex';
import { GlSprintf, GlIcon } from '@gitlab/ui';
import { IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants';
import { sprintf, __ } from '~/locale';
@ -78,7 +78,6 @@ export default {
},
},
methods: {
...mapActions('batchComments', ['scrollToDraft']),
getLineClasses(lineNumber) {
return getLineClasses(lineNumber);
},
@ -88,17 +87,7 @@ export default {
</script>
<template>
<button
type="button"
class="review-preview-item menu-item"
:class="[
componentClasses,
{
'is-last': isLast,
},
]"
@click="scrollToDraft(draft)"
>
<span>
<span class="review-preview-item-header">
<gl-icon class="flex-shrink-0" :name="iconName" />
<span
@ -139,5 +128,5 @@ export default {
>
<gl-icon class="gl-mr-3" name="status_success" /> {{ resolvedStatusMessage }}
</span>
</button>
</span>
</template>

View file

@ -1,7 +1,6 @@
<script>
import { mapActions, mapState } from 'vuex';
import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
import DraftsCount from './drafts_count.vue';
export default {
@ -15,11 +14,6 @@ export default {
required: false,
default: false,
},
label: {
type: String,
required: false,
default: __('Finish review'),
},
category: {
type: String,
required: false,
@ -30,22 +24,14 @@ export default {
required: false,
default: 'success',
},
shouldPublish: {
type: Boolean,
required: true,
},
},
computed: {
...mapState('batchComments', ['isPublishing']),
},
methods: {
...mapActions('batchComments', ['publishReview', 'toggleReviewDropdown']),
...mapActions('batchComments', ['publishReview']),
onClick() {
if (this.shouldPublish) {
this.publishReview();
} else {
this.toggleReviewDropdown();
}
this.publishReview();
},
},
};
@ -59,7 +45,7 @@ export default {
:variant="variant"
@click="onClick"
>
{{ label }}
{{ __('Submit review') }}
<drafts-count v-if="showCount" />
</gl-button>
</template>

View file

@ -1,22 +1,15 @@
<script>
/* eslint-disable vue/no-v-html */
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlModal, GlModalDirective, GlButton } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import { mapActions, mapGetters } from 'vuex';
import PreviewDropdown from './preview_dropdown.vue';
import PublishButton from './publish_button.vue';
export default {
components: {
GlButton,
GlModal,
PreviewDropdown,
},
directives: {
'gl-modal': GlModalDirective,
PublishButton,
},
computed: {
...mapGetters(['isNotesFetched']),
...mapState('batchComments', ['isDiscarding']),
...mapGetters('batchComments', ['draftsCount']),
},
watch: {
@ -27,45 +20,17 @@ export default {
},
},
methods: {
...mapActions('batchComments', ['discardReview', 'expandAllDiscussions']),
...mapActions('batchComments', ['expandAllDiscussions']),
},
modalId: 'discard-draft-review',
text: sprintf(
s__(
`BatchComments|You're about to discard your review which will delete all of your pending comments.
The deleted comments %{strong_start}cannot%{strong_end} be restored.`,
),
{
strong_start: '<strong>',
strong_end: '</strong>',
},
false,
),
};
</script>
<template>
<div v-show="draftsCount > 0">
<nav class="review-bar-component">
<div class="review-bar-content qa-review-bar">
<div class="review-bar-content qa-review-bar d-flex gl-justify-content-end">
<preview-dropdown />
<gl-button
v-gl-modal="$options.modalId"
:loading="isDiscarding"
class="qa-discard-review float-right"
>
{{ __('Discard review') }}
</gl-button>
<publish-button class="gl-ml-3" show-count />
</div>
</nav>
<gl-modal
:title="s__('BatchComments|Discard review?')"
:ok-title="s__('BatchComments|Delete all pending comments')"
:modal-id="$options.modalId"
title-tag="h4"
ok-variant="danger qa-modal-delete-pending-comments"
@ok="discardReview"
>
<p v-html="$options.text"></p>
</gl-modal>
</div>
</template>

View file

@ -75,15 +75,6 @@ export const updateDiscussionsAfterPublish = ({ dispatch, getters, rootGetters }
}),
);
export const discardReview = ({ commit, getters }) => {
commit(types.REQUEST_DISCARD_REVIEW);
return service
.discard(getters.getNotesData.draftsDiscardPath)
.then(() => commit(types.RECEIVE_DISCARD_REVIEW_SUCCESS))
.catch(() => commit(types.RECEIVE_DISCARD_REVIEW_ERROR));
};
export const updateDraft = (
{ commit, getters },
{ note, noteText, resolveDiscussion, position, callback },
@ -108,8 +99,6 @@ export const scrollToDraft = ({ dispatch, rootGetters }, draft) => {
const draftID = `note_${draft.id}`;
const el = document.querySelector(`#${tabEl} #${draftID}`);
dispatch('closeReviewDropdown');
window.location.hash = draftID;
if (window.mrTabs.currentAction !== tab) {
@ -125,17 +114,6 @@ export const scrollToDraft = ({ dispatch, rootGetters }, draft) => {
}
};
export const toggleReviewDropdown = ({ dispatch, state }) => {
if (state.showPreviewDropdown) {
dispatch('closeReviewDropdown');
} else {
dispatch('openReviewDropdown');
}
};
export const openReviewDropdown = ({ commit }) => commit(types.OPEN_REVIEW_DROPDOWN);
export const closeReviewDropdown = ({ commit }) => commit(types.CLOSE_REVIEW_DROPDOWN);
export const expandAllDiscussions = ({ dispatch, state }) =>
state.drafts
.filter(draft => draft.discussion_id)

View file

@ -11,13 +11,6 @@ export const REQUEST_PUBLISH_REVIEW = 'REQUEST_PUBLISH_REVIEW';
export const RECEIVE_PUBLISH_REVIEW_SUCCESS = 'RECEIVE_PUBLISH_REVIEW_SUCCESS';
export const RECEIVE_PUBLISH_REVIEW_ERROR = 'RECEIVE_PUBLISH_REVIEW_ERROR';
export const REQUEST_DISCARD_REVIEW = 'REQUEST_DISCARD_REVIEW';
export const RECEIVE_DISCARD_REVIEW_SUCCESS = 'RECEIVE_DISCARD_REVIEW_SUCCESS';
export const RECEIVE_DISCARD_REVIEW_ERROR = 'RECEIVE_DISCARD_REVIEW_ERROR';
export const RECEIVE_DRAFT_UPDATE_SUCCESS = 'RECEIVE_DRAFT_UPDATE_SUCCESS';
export const OPEN_REVIEW_DROPDOWN = 'OPEN_REVIEW_DROPDOWN';
export const CLOSE_REVIEW_DROPDOWN = 'CLOSE_REVIEW_DROPDOWN';
export const TOGGLE_RESOLVE_DISCUSSION = 'TOGGLE_RESOLVE_DISCUSSION';

View file

@ -43,16 +43,6 @@ export default {
[types.RECEIVE_PUBLISH_REVIEW_ERROR](state) {
state.isPublishing = false;
},
[types.REQUEST_DISCARD_REVIEW](state) {
state.isDiscarding = true;
},
[types.RECEIVE_DISCARD_REVIEW_SUCCESS](state) {
state.isDiscarding = false;
state.drafts = [];
},
[types.RECEIVE_DISCARD_REVIEW_ERROR](state) {
state.isDiscarding = false;
},
[types.RECEIVE_DRAFT_UPDATE_SUCCESS](state, data) {
const index = state.drafts.findIndex(draft => draft.id === data.id);
@ -60,12 +50,6 @@ export default {
state.drafts.splice(index, 1, processDraft(data));
}
},
[types.OPEN_REVIEW_DROPDOWN](state) {
state.showPreviewDropdown = true;
},
[types.CLOSE_REVIEW_DROPDOWN](state) {
state.showPreviewDropdown = false;
},
[types.TOGGLE_RESOLVE_DISCUSSION](state, draftId) {
state.drafts = state.drafts.map(draft => {
if (draft.id === draftId) {

View file

@ -4,6 +4,4 @@ export default () => ({
drafts: [],
isPublishing: false,
currentlyPublishingDrafts: [],
isDiscarding: false,
showPreviewDropdown: false,
});

View file

@ -1,14 +1,16 @@
import Vue from 'vue';
import ConfirmModal from '~/vue_shared/components/confirm_modal.vue';
const mountConfirmModal = () => {
return new Vue({
const mountConfirmModal = optionalProps =>
new Vue({
render(h) {
return h(ConfirmModal, {
props: { selector: '.js-confirm-modal-button' },
props: {
selector: '.js-confirm-modal-button',
...optionalProps,
},
});
},
}).$mount();
};
export default () => mountConfirmModal();
export default (optionalProps = {}) => mountConfirmModal(optionalProps);

View file

@ -53,6 +53,7 @@ export default {
:aria-label="leaveBtnTitle"
data-container="body"
data-placement="bottom"
data-testid="leave-group-btn"
class="leave-group btn btn-xs no-expand gl-text-gray-500 gl-ml-5"
@click.prevent="onLeaveGroup"
>
@ -66,6 +67,7 @@ export default {
:aria-label="editBtnTitle"
data-container="body"
data-placement="bottom"
data-testid="edit-group-btn"
class="edit-group btn btn-xs no-expand gl-text-gray-500 gl-ml-5"
>
<gl-icon name="settings" class="position-top-0 align-middle" />

View file

@ -57,6 +57,7 @@ export default {
:title="title"
data-container="body"
>
<gl-icon :name="iconName" /> <span v-if="isValuePresent" class="stat-value"> {{ value }} </span>
<gl-icon :name="iconName" />
<span v-if="isValuePresent" class="stat-value" data-testid="itemStatValue"> {{ value }} </span>
</span>
</template>

View file

@ -1,5 +1,4 @@
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
import $ from 'jquery';
import { GlIcon } from '@gitlab/ui';
import IssuableTemplateSelectors from '../../../templates/issuable_template_selectors';
@ -62,11 +61,15 @@ export default {
data-toggle="dropdown"
>
<span class="dropdown-toggle-text">{{ __('Choose a template') }}</span>
<i aria-hidden="true" class="fa fa-chevron-down"> </i>
<gl-icon
name="chevron-down"
class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
aria-hidden="true"
/>
</button>
<div class="dropdown-menu dropdown-select">
<div class="dropdown-title gl-display-flex gl-justify-content-center">
<span class="gl-ml-auto">Choose a template</span>
<span class="gl-ml-auto">{{ __('Choose a template') }}</span>
<button
class="dropdown-title-button dropdown-menu-close gl-ml-auto"
:aria-label="__('Close')"
@ -82,7 +85,7 @@ export default {
:placeholder="__('Filter')"
autocomplete="off"
/>
<i aria-hidden="true" class="fa fa-search dropdown-input-search"> </i>
<gl-icon name="search" class="dropdown-input-search" aria-hidden="true" />
<gl-icon
name="close"
class="dropdown-input-clear js-dropdown-input-clear"

View file

@ -255,6 +255,15 @@ export function getBaseURL() {
return `${protocol}//${host}`;
}
/**
* Takes a URL and returns content from the start until the final '/'
*
* @param {String} url - full url, including protocol and host
*/
export function stripFinalUrlSegment(url) {
return new URL('.', url).href;
}
/**
* Returns true if url is an absolute URL
*

View file

@ -0,0 +1,12 @@
import { initRemoveTag } from '../remove_tag';
document.addEventListener('DOMContentLoaded', () => {
initRemoveTag({
onDelete: path => {
document
.querySelector(`[data-path="${path}"]`)
.closest('.js-tag-list')
.remove();
},
});
});

View file

@ -0,0 +1,16 @@
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import initConfirmModal from '~/confirm_modal';
export const initRemoveTag = ({ onDelete = () => {} }) => {
return initConfirmModal({
handleSubmit: (path = '') =>
axios
.delete(path)
.then(() => onDelete(path))
.catch(({ response: { data } }) => {
const { message } = data;
createFlash({ message });
}),
});
};

View file

@ -0,0 +1,10 @@
import { redirectTo, getBaseURL, stripFinalUrlSegment } from '~/lib/utils/url_utility';
import { initRemoveTag } from '../remove_tag';
document.addEventListener('DOMContentLoaded', () => {
initRemoveTag({
onDelete: (path = '') => {
redirectTo(stripFinalUrlSegment([getBaseURL(), path].join('')));
},
});
});

View file

@ -0,0 +1,38 @@
<script>
import { GlSprintf, GlAlert, GlLink } from '@gitlab/ui';
import { DELETE_ALERT_TITLE, DELETE_ALERT_LINK_TEXT } from '../../constants/index';
export default {
components: {
GlSprintf,
GlAlert,
GlLink,
},
props: {
runCleanupPoliciesHelpPagePath: { type: String, required: false, default: '' },
cleanupPoliciesHelpPagePath: { type: String, required: false, default: '' },
},
i18n: {
DELETE_ALERT_TITLE,
DELETE_ALERT_LINK_TEXT,
},
};
</script>
<template>
<gl-alert variant="warning" :title="$options.i18n.DELETE_ALERT_TITLE" @dismiss="$emit('dismiss')">
<gl-sprintf :message="$options.i18n.DELETE_ALERT_LINK_TEXT">
<template #adminLink="{content}">
<gl-link data-testid="run-link" :href="runCleanupPoliciesHelpPagePath" target="_blank">{{
content
}}</gl-link>
</template>
<template #docLink="{content}">
<gl-link data-testid="help-link" :href="cleanupPoliciesHelpPagePath" target="_blank">{{
content
}}</gl-link>
</template>
</gl-sprintf>
</gl-alert>
</template>

View file

@ -42,6 +42,7 @@ export default {
name: this.item.path,
tags_path: this.item.tags_path,
id: this.item.id,
cleanup_policy_started_at: this.item.cleanup_policy_started_at,
});
return window.btoa(params);
},

View file

@ -9,3 +9,7 @@ export const EXPIRATION_POLICY_DISABLED_TEXT = s__(
export const EXPIRATION_POLICY_DISABLED_MESSAGE = s__(
'ContainerRegistry|Expiration policies help manage the storage space used by the Container Registry, but the expiration policies for this registry are disabled. Contact your administrator to enable. %{docLinkStart}More information%{docLinkEnd}',
);
export const DELETE_ALERT_TITLE = s__('ContainerRegistry|Some tags were not deleted');
export const DELETE_ALERT_LINK_TEXT = s__(
'ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}manually run cleanup now%{adminLinkEnd} or you can wait for the cleanup policy to automatically run again. %{docLinkStart}More information%{docLinkEnd}',
);

View file

@ -4,6 +4,7 @@ import { GlPagination, GlResizeObserverDirective } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import Tracking from '~/tracking';
import DeleteAlert from '../components/details_page/delete_alert.vue';
import PartialCleanupAlert from '../components/details_page/partial_cleanup_alert.vue';
import DeleteModal from '../components/details_page/delete_modal.vue';
import DetailsHeader from '../components/details_page/details_header.vue';
import TagsList from '../components/details_page/tags_list.vue';
@ -21,6 +22,7 @@ import {
export default {
components: {
DeleteAlert,
PartialCleanupAlert,
DetailsHeader,
GlPagination,
DeleteModal,
@ -37,13 +39,16 @@ export default {
itemsToBeDeleted: [],
isDesktop: true,
deleteAlertType: null,
dismissPartialCleanupWarning: false,
};
},
computed: {
...mapState(['tagsPagination', 'isLoading', 'config', 'tags']),
imageName() {
const { name } = decodeAndParse(this.$route.params.id);
return name;
queryParameters() {
return decodeAndParse(this.$route.params.id);
},
showPartialCleanupWarning() {
return this.queryParameters.cleanup_policy_started_at && !this.dismissPartialCleanupWarning;
},
tracking() {
return {
@ -120,7 +125,14 @@ export default {
class="gl-my-2"
/>
<details-header :image-name="imageName" />
<partial-cleanup-alert
v-if="showPartialCleanupWarning"
:run-cleanup-policies-help-page-path="config.runCleanupPoliciesHelpPagePath"
:cleanup-policies-help-page-path="config.cleanupPoliciesHelpPagePath"
@dismiss="dismissPartialCleanupWarning = true"
/>
<details-header :image-name="queryParameters.name" />
<tags-loader v-if="isLoading" />
<template v-else>

View file

@ -1,13 +1,12 @@
<script>
import { GlIcon } from '@gitlab/ui';
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import statusIcon from '../mr_widget_status_icon.vue';
export default {
name: 'MRWidgetMissingBranch',
directives: {
tooltip,
GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
@ -52,7 +51,7 @@ export default {
<span class="bold js-branch-text">
<span class="capitalize"> {{ missingBranchName }} </span>
{{ s__('mrWidget|branch does not exist.') }} {{ missingBranchNameMessage }}
<gl-icon v-tooltip :title="message" :aria-label="message" name="question-o" />
<gl-icon v-gl-tooltip :title="message" :aria-label="message" name="question-o" />
</span>
</div>
</div>

View file

@ -12,6 +12,11 @@ export default {
type: String,
required: true,
},
handleSubmit: {
type: Function,
required: false,
default: null,
},
},
data() {
return {
@ -41,7 +46,11 @@ export default {
this.$refs.modal.hide();
},
submitModal() {
this.$refs.form.submit();
if (this.handleSubmit) {
this.handleSubmit(this.path);
} else {
this.$refs.form.submit();
}
},
},
csrf,

View file

@ -3,7 +3,6 @@
color: $gl-text-color;
border: 1px solid $border-color;
border-radius: $border-radius-default;
margin-bottom: $gl-padding-8;
.card.card-body-segment {
padding: $gl-padding;

View file

@ -778,7 +778,7 @@
}
.btn {
margin-top: $gl-padding-8;
margin-bottom: $gl-padding-8;
padding: $gl-btn-vert-padding $gl-btn-padding;
line-height: $gl-btn-line-height;

View file

@ -15,12 +15,14 @@ module ApplicationCable
private
def find_user_from_session_store
session = ActiveSession.sessions_from_ids([session_id.private_id]).first
session = ActiveSession.sessions_from_ids(Array.wrap(session_id)).first
Warden::SessionSerializer.new('rack.session' => session).fetch(:user)
end
def session_id
Rack::Session::SessionId.new(cookies[Gitlab::Application.config.session_options[:key]])
session_cookie = cookies[Gitlab::Application.config.session_options[:key]]
Rack::Session::SessionId.new(session_cookie).private_id if session_cookie.present?
end
def notification_payload(_)

View file

@ -21,6 +21,8 @@ module Boards
before_action :validate_id_list, only: [:bulk_move]
before_action :can_move_issues?, only: [:bulk_move]
feature_category :boards
def index
list_service = Boards::Issues::ListService.new(board_parent, current_user, filter_params)
issues = issues_from(list_service)

View file

@ -8,6 +8,8 @@ module Boards
before_action :authorize_read_list, only: [:index]
skip_before_action :authenticate_user!, only: [:index]
feature_category :boards
def index
lists = Boards::Lists::ListService.new(board.resource_parent, current_user).execute(board)

View file

@ -5,35 +5,38 @@ module ControllerWithFeatureCategory
include Gitlab::ClassAttributes
class_methods do
def feature_category(category, config = {})
validate_config!(config)
def feature_category(category, actions = [])
feature_category_configuration[category] ||= []
feature_category_configuration[category] += actions.map(&:to_s)
category_config = Config.new(category, config[:only], config[:except], config[:if], config[:unless])
# Add the config to the beginning. That way, the last defined one takes precedence.
feature_category_configuration.unshift(category_config)
validate_config!(feature_category_configuration)
end
def feature_category_for_action(action)
category_config = feature_category_configuration.find { |config| config.matches?(action) }
category_config = feature_category_configuration.find do |_, actions|
actions.empty? || actions.include?(action)
end
category_config&.category || superclass_feature_category_for_action(action)
category_config&.first || superclass_feature_category_for_action(action)
end
private
def validate_config!(config)
invalid_keys = config.keys - [:only, :except, :if, :unless]
if invalid_keys.any?
raise ArgumentError, "unknown arguments: #{invalid_keys} "
empty = config.find { |_, actions| actions.empty? }
duplicate_actions = config.values.flatten.group_by(&:itself).select { |_, v| v.count > 1 }.keys
if config.length > 1 && empty
raise ArgumentError, "#{empty.first} is defined for all actions, but other categories are set"
end
if config.key?(:only) && config.key?(:except)
raise ArgumentError, "cannot configure both `only` and `except`"
if duplicate_actions.any?
raise ArgumentError, "Actions have multiple feature categories: #{duplicate_actions.join(', ')}"
end
end
def feature_category_configuration
class_attributes[:feature_category_config] ||= []
class_attributes[:feature_category_config] ||= {}
end
def superclass_feature_category_for_action(action)

View file

@ -1,38 +0,0 @@
# frozen_string_literal: true
module ControllerWithFeatureCategory
class Config
attr_reader :category
def initialize(category, only, except, if_proc, unless_proc)
@category = category.to_sym
@only, @except = only&.map(&:to_s), except&.map(&:to_s)
@if_proc, @unless_proc = if_proc, unless_proc
end
def matches?(action)
included?(action) && !excluded?(action) &&
if_proc?(action) && !unless_proc?(action)
end
private
attr_reader :only, :except, :if_proc, :unless_proc
def if_proc?(action)
if_proc.nil? || if_proc.call(action)
end
def unless_proc?(action)
unless_proc.present? && unless_proc.call(action)
end
def included?(action)
only.nil? || only.include?(action)
end
def excluded?(action)
except.present? && except.include?(action)
end
end
end

View file

@ -3,6 +3,8 @@
class ConfirmationsController < Devise::ConfirmationsController
include AcceptsPendingInvitations
feature_category :users
def almost_there
flash[:notice] = nil
render layout: "devise_empty"

View file

@ -5,6 +5,8 @@ class Dashboard::GroupsController < Dashboard::ApplicationController
skip_cross_project_access_check :index
feature_category :subgroups
def index
groups = GroupsFinder.new(current_user, all_available: false).execute
render_group_tree(groups)

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
class Dashboard::LabelsController < Dashboard::ApplicationController
feature_category :issue_tracking
def index
respond_to do |format|
format.json { render json: LabelSerializer.new.represent_appearance(labels) }

View file

@ -4,6 +4,8 @@ class Dashboard::MilestonesController < Dashboard::ApplicationController
before_action :projects
before_action :groups, only: :index
feature_category :issue_tracking
def index
respond_to do |format|
format.html do

View file

@ -14,6 +14,8 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
before_action :projects, only: [:index]
skip_cross_project_access_check :index, :starred
feature_category :projects
def index
respond_to do |format|
format.html do

View file

@ -7,6 +7,8 @@ class Dashboard::SnippetsController < Dashboard::ApplicationController
skip_cross_project_access_check :index
feature_category :snippets
def index
@snippet_counts = Snippets::CountService
.new(current_user, author: current_user)

View file

@ -9,6 +9,8 @@ class Dashboard::TodosController < Dashboard::ApplicationController
before_action :authorize_read_group!, only: :index
before_action :find_todos, only: [:index, :destroy_all]
feature_category :issue_tracking
def index
@sort = params[:sort]
@todos = @todos.page(params[:page])

View file

@ -15,6 +15,10 @@ class DashboardController < Dashboard::ApplicationController
respond_to :html
feature_category :audit_events, [:activity]
feature_category :issue_tracking, [:issues, :issues_calendar]
feature_category :code_review, [:merge_requests]
def activity
respond_to do |format|
format.html

View file

@ -3,6 +3,8 @@
class Explore::GroupsController < Explore::ApplicationController
include GroupTree
feature_category :subgroups
def index
render_group_tree GroupsFinder.new(current_user).execute
end

View file

@ -18,6 +18,8 @@ class Explore::ProjectsController < Explore::ApplicationController
rescue_from PageOutOfBoundsError, with: :page_out_of_bounds
feature_category :projects
def index
@projects = load_projects

View file

@ -3,6 +3,8 @@
class Explore::SnippetsController < Explore::ApplicationController
include Gitlab::NoteableMetadata
feature_category :snippets
def index
@snippets = SnippetsFinder.new(current_user, explore: true)
.execute

View file

@ -50,12 +50,17 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
after_action :log_merge_request_show, only: [:show]
feature_category :source_code_management,
unless: -> (action) { action.ends_with?("_reports") }
feature_category :code_testing,
only: [:test_reports, :coverage_reports, :terraform_reports]
feature_category :accessibility_testing,
only: [:accessibility_reports]
feature_category :code_review, [
:assign_related_issues, :bulk_update, :cancel_auto_merge,
:ci_environments_status, :commit_change_content, :commits,
:context_commits, :destroy, :diff_for_path, :discussions,
:edit, :exposed_artifacts, :index, :merge,
:pipeline_status, :pipelines, :rebase, :remove_wip, :show,
:toggle_award_emoji, :toggle_subscription, :update
]
feature_category :code_testing, [:test_reports, :coverage_reports, :terraform_reports]
feature_category :accessibility_testing, [:accessibility_reports]
def index
@merge_requests = @issuables

View file

@ -76,25 +76,10 @@ class Projects::TagsController < Projects::ApplicationController
def destroy
result = ::Tags::DestroyService.new(project, current_user).execute(params[:id])
respond_to do |format|
if result[:status] == :success
format.html do
redirect_to project_tags_path(@project), status: :see_other
end
format.js
else
@error = result[:message]
format.html do
redirect_to project_tags_path(@project),
alert: @error, status: :see_other
end
format.js do
render status: :ok
end
end
if result[:status] == :success
render json: result
else
render json: { message: result[:message] }, status: result[:return_code]
end
end

View file

@ -167,7 +167,7 @@ module ApplicationSettingsHelper
end
def visible_attributes
[
attributes = [
:abuse_notification_email,
:after_sign_out_path,
:after_sign_up_text,
@ -331,6 +331,9 @@ module ApplicationSettingsHelper
:wiki_page_max_content_bytes,
:container_registry_delete_tags_service_timeout
]
attributes << :require_admin_approval_after_user_signup if Feature.enabled?(:admin_approval_for_new_user_signups)
attributes
end
def external_authorization_service_attributes

View file

@ -38,4 +38,13 @@ module TagsHelper
text.html_safe
end
def delete_tag_modal_attributes(tag_name)
{
title: s_('TagsPage|Delete tag'),
message: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: tag_name },
okVariant: 'danger',
okTitle: s_('TagsPage|Delete tag')
}.to_json
end
end

View file

@ -56,6 +56,7 @@ class Issue < ApplicationRecord
dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :issue_assignees
has_many :issue_email_participants
has_many :assignees, class_name: "User", through: :issue_assignees
has_many :zoom_meetings
has_many :user_mentions, class_name: "IssueUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
class IssueEmailParticipant < ApplicationRecord
belongs_to :issue
validates :email, presence: true, uniqueness: { scope: [:issue_id] }
validates :issue, presence: true
validate :validate_email_format
def validate_email_format
self.errors.add(:email, I18n.t(:invalid, scope: 'valid_email.validations.email')) unless ValidateEmail.valid?(self.email)
end
end

View file

@ -199,6 +199,7 @@ class Project < ApplicationRecord
has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :export_jobs, class_name: 'ProjectExportJob'
has_one :project_repository, inverse_of: :project
has_one :tracing_setting, class_name: 'ProjectTracingSetting'
has_one :incident_management_setting, inverse_of: :project, class_name: 'IncidentManagement::ProjectIncidentManagementSetting'
has_one :error_tracking_setting, inverse_of: :project, class_name: 'ErrorTracking::ProjectErrorTrackingSetting'
has_one :metrics_setting, inverse_of: :project, class_name: 'ProjectMetricsSetting'
@ -564,6 +565,7 @@ class Project < ApplicationRecord
}
scope :imported_from, -> (type) { where(import_type: type) }
scope :with_tracing_enabled, -> { joins(:tracing_setting) }
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
@ -2523,6 +2525,10 @@ class Project < ApplicationRecord
instance.token
end
def tracing_external_url
tracing_setting&.external_url
end
private
def find_service(services, name)

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
class ProjectTracingSetting < ApplicationRecord
belongs_to :project
validates :external_url, length: { maximum: 255 }, public_url: true
before_validation :sanitize_external_url
private
def sanitize_external_url
self.external_url = Rails::Html::FullSanitizer.new.sanitize(self.external_url)
end
end

View file

@ -64,11 +64,6 @@ class User < ApplicationRecord
# and should be added after Devise modules are initialized.
include AsyncDeviseEmail
BLOCKED_MESSAGE = "Your account has been blocked. Please contact your GitLab " \
"administrator if you think this is an error."
LOGIN_FORBIDDEN = "Your account does not have the required permission to login. Please contact your GitLab " \
"administrator if you think this is an error."
MINIMUM_INACTIVE_DAYS = 90
# Override Devise::Models::Trackable#update_tracked_fields!
@ -381,11 +376,12 @@ class User < ApplicationRecord
super && can?(:log_in)
end
# The messages for these keys are defined in `devise.en.yml`
def inactive_message
if blocked?
BLOCKED_MESSAGE
:blocked
elsif internal?
LOGIN_FORBIDDEN
:forbidden
else
super
end

View file

@ -15,6 +15,7 @@ module Releases
# - Project
# - Milestones
# - Issues
# TODO: remove issues from this check: https://gitlab.com/gitlab-org/gitlab/-/issues/259674
condition(:allowed_to_read_evidence) do
can?(:read_release) &&
can?(:download_code) &&

View file

@ -4,6 +4,7 @@ class ContainerRepositoryEntity < Grape::Entity
include RequestAwareEntity
expose :id, :name, :path, :location, :created_at, :status, :tags_count
expose :expiration_policy_started_at, as: :cleanup_policy_started_at
expose :tags_path do |repository|
project_registry_repository_tags_path(project, repository, format: :json)

View file

@ -70,7 +70,9 @@ module Ci
end
def validate_build_trace!
if chunks_persisted?
return unless has_chunks?
unless live_chunks_pending?
metrics.increment_trace_operation(operation: :finalized)
end
@ -110,8 +112,8 @@ module Ci
build.trace_chunks.live.any?
end
def chunks_persisted?
build.trace_chunks.any? && !live_chunks_pending?
def has_chunks?
build.trace_chunks.any?
end
def pending_state_outdated?

View file

@ -246,7 +246,7 @@ module DesignManagement
new_designs = DesignManagement::Design.unscoped.find(design_ids)
# Execute another query to filter only designs with notes
DesignManagement::Design.unscoped.where(id: designs).joins(:notes).find_each(batch_size: 100) do |old_design|
DesignManagement::Design.unscoped.where(id: designs).joins(:notes).distinct.find_each(batch_size: 100) do |old_design|
new_design = new_designs.find { |d| d.filename == old_design.filename }
Notes::CopyService.new(current_user, old_design, new_design).execute

View file

@ -10,8 +10,6 @@ module SystemNotes
#
# Returns the created Note object
def change_incident_severity
return unless Feature.enabled?(:add_severity_system_note, noteable.project)
severity = noteable.severity
if severity_label = IssuableSeverity::SEVERITY_LABELS[severity.to_sym]

View file

@ -9,6 +9,14 @@
Sign-up enabled
.form-text.text-muted
= _("When enabled, any user visiting %{host} will be able to create an account.") % { host: "#{new_user_session_url(host: Gitlab.config.gitlab.host)}" }
- if Feature.enabled?(:admin_approval_for_new_user_signups)
.form-group
.form-check
= f.check_box :require_admin_approval_after_user_signup, class: 'form-check-input'
= f.label :require_admin_approval_after_user_signup, class: 'form-check-label' do
= _('Require admin approval for new sign-ups')
.form-text.text-muted
= _("When enabled, any user visiting %{host} and creating an account will have to be explicitly approved by the admin before they can login. This setting is effective only if sign-ups are enabled.") % { host: "#{new_user_session_url(host: Gitlab.config.gitlab.host)}" }
.form-group
.form-check
= f.check_box :send_user_confirmation_email, class: 'form-check-input'

View file

@ -12,6 +12,8 @@
"containers_error_image" => image_path('illustrations/docker-error-state.svg'),
"registry_host_url_with_port" => escape_once(registry_config.host_port),
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'),
"run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'),
"cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'),
"is_admin": current_user&.admin.to_s,
is_group_page: "true",
character_error: @character_error.to_s } }

View file

@ -64,12 +64,12 @@
- else
= _('Register Universal Two-Factor (U2F) Device')
%p
= _('Use a hardware device to add the second factor of authentication.')
= _('Set up a hardware device as a second factor to sign in.')
%p
- if webauthn_enabled
= _("As WebAuthn devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a WebAuthn device. That way you'll always be able to log in - even when you're using an unsupported browser.")
= _("Not all browsers support WebAuthn. Therefore, we require that you set up a two-factor authentication app first. That way you'll always be able to sign in - even from an unsupported browser.")
- else
= _("As U2F devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a U2F device. That way you'll always be able to log in - even when you're using an unsupported browser.")
= _("Not all browsers support U2F devices. Therefore, we require that you set up a two-factor authentication app first. That way you'll always be able to sign in - even when you're using an unsupported browser.")
.col-lg-8
- registration = webauthn_enabled ? @webauthn_registration : @u2f_registration
- if registration.errors.present?

View file

@ -0,0 +1,6 @@
- project = local_assigns.fetch(:project, nil)
- tag = local_assigns.fetch(:tag, nil)
- return unless project && tag
%button{ type: "button", class: "js-remove-tag js-confirm-modal-button gl-button btn btn-remove remove-row has-tooltip gl-ml-3 #{protected_tag?(project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), data: { container: 'body', path: project_tag_path(@project, tag.name), modal_attributes: delete_tag_modal_attributes(tag.name) } }
= sprite_icon("remove")

View file

@ -15,5 +15,8 @@
"registry_host_url_with_port" => escape_once(registry_config.host_port),
"expiration_policy_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'expiration-policy'),
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'),
"run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'),
"cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'),
"is_admin": current_user&.admin.to_s,
character_error: @character_error.to_s } }

View file

@ -1,6 +1,7 @@
- commit = @repository.commit(tag.dereferenced_target)
- release = @releases.find { |release| release.tag == tag.name }
%li.flex-row.allow-wrap
%li.flex-row.allow-wrap.js-tag-list
.row-main-content
= sprite_icon('tag')
= link_to tag.name, project_tag_path(@project, tag.name), class: 'item-title ref-name'
@ -38,5 +39,4 @@
- if can?(current_user, :admin_tag, @project)
= link_to edit_project_tag_release_path(@project, tag.name), class: 'btn btn-edit has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do
= sprite_icon("pencil")
= link_to project_tag_path(@project, tag.name), class: "btn btn-remove remove-row has-tooltip gl-ml-3 #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: tag.name }, container: 'body' }, remote: true do
= sprite_icon("remove")
= render 'projects/buttons/remove_tag', project: @project, tag: tag

View file

@ -1,4 +0,0 @@
- if @error.present?
new Flash({ message: '#{escape_javascript(@error)}', type: 'alert' });
- elsif @repository.tags.empty?
$('.tags').load(document.URL + ' .nothing-here-block').hide().fadeIn(1000)

View file

@ -52,8 +52,7 @@
= render 'projects/buttons/download', project: @project, ref: @tag.name
- if can?(current_user, :admin_tag, @project)
.btn-container.controls-item-full
= link_to project_tag_path(@project, @tag.name), class: "btn btn-icon btn-danger gl-button remove-row has-tooltip #{protected_tag?(@project, @tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: @tag.name } } do
= sprite_icon('remove', css_class: 'gl-icon')
= render 'projects/buttons/remove_tag', project: @project, tag: @tag
- if @tag.message.present?
%pre.wrap{ data: { qa_selector: 'tag_message_content' } }

View file

@ -0,0 +1,5 @@
---
title: Add issue_email_participants table and related model
merge_request: 42943
author:
type: other

View file

@ -0,0 +1,5 @@
---
title: Display alert for partially executed cleanup policies
merge_request: 43831
author:
type: changed

View file

@ -0,0 +1,5 @@
---
title: "Description Templates: Replace fontawesome icons with GitLab SVGs"
merge_request: 43379
author:
type: changed

View file

@ -0,0 +1,5 @@
---
title: Improve two button review submit in merge requests
merge_request: 43149
author:
type: changed

View file

@ -0,0 +1,5 @@
---
title: Move Tracing usage data ping to Core
merge_request: 44006
author:
type: added

View file

@ -0,0 +1,5 @@
---
title: Add system note on incident severity change
merge_request: 43998
author:
type: added

View file

@ -1,7 +1,7 @@
---
name: add_severity_system_note
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/42358
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/251110
group: group::health
name: admin_approval_for_new_user_signups
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/43827
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/258980
type: development
group: group::access
default_enabled: false

View file

@ -46,7 +46,7 @@ end
module ActiveRecord
class Base
module SkipTransactionCheckAfterCommit
def committed!(*)
def committed!(*args, **kwargs)
Sidekiq::Worker.skipping_transaction_check { super }
end
end

View file

@ -16,6 +16,8 @@ en:
timeout: "Your session expired. Please sign in again to continue."
unauthenticated: "You need to sign in or sign up before continuing."
unconfirmed: "You have to confirm your email address before continuing. Please check your email for the link we sent you, or click 'Resend confirmation email'."
blocked: "Your account has been blocked. Please contact your GitLab administrator if you think this is an error."
forbidden: "Your account does not have the required permission to login. Please contact your GitLab administrator if you think this is an error."
mailer:
confirmation_instructions:
subject: "Confirmation instructions"

View file

@ -0,0 +1,32 @@
# frozen_string_literal: true
class CreateIssueEmailParticipants < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
unless table_exists?(:issue_email_participants)
with_lock_retries do
create_table :issue_email_participants do |t|
t.references :issue, index: false, null: false, foreign_key: { on_delete: :cascade }
t.datetime_with_timezone :created_at, null: false
t.datetime_with_timezone :updated_at, null: false
t.text :email, null: false
t.index [:issue_id, :email], unique: true
end
end
add_text_limit(:issue_email_participants, :email, 255)
end
end
def down
with_lock_retries do
drop_table :issue_email_participants
end
end
end

View file

@ -0,0 +1 @@
384d022662437de21b4b3b97bf2f1dec2925be6afe4b62828c97dc9b3b3fc77c

View file

@ -12676,6 +12676,24 @@ CREATE TABLE issue_assignees (
issue_id integer NOT NULL
);
CREATE TABLE issue_email_participants (
id bigint NOT NULL,
issue_id bigint NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
email text NOT NULL,
CONSTRAINT check_2c321d408d CHECK ((char_length(email) <= 255))
);
CREATE SEQUENCE issue_email_participants_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE issue_email_participants_id_seq OWNED BY issue_email_participants.id;
CREATE TABLE issue_links (
id integer NOT NULL,
source_id integer NOT NULL,
@ -17447,6 +17465,8 @@ ALTER TABLE ONLY ip_restrictions ALTER COLUMN id SET DEFAULT nextval('ip_restric
ALTER TABLE ONLY issuable_severities ALTER COLUMN id SET DEFAULT nextval('issuable_severities_id_seq'::regclass);
ALTER TABLE ONLY issue_email_participants ALTER COLUMN id SET DEFAULT nextval('issue_email_participants_id_seq'::regclass);
ALTER TABLE ONLY issue_links ALTER COLUMN id SET DEFAULT nextval('issue_links_id_seq'::regclass);
ALTER TABLE ONLY issue_metrics ALTER COLUMN id SET DEFAULT nextval('issue_metrics_id_seq'::regclass);
@ -18565,6 +18585,9 @@ ALTER TABLE ONLY ip_restrictions
ALTER TABLE ONLY issuable_severities
ADD CONSTRAINT issuable_severities_pkey PRIMARY KEY (id);
ALTER TABLE ONLY issue_email_participants
ADD CONSTRAINT issue_email_participants_pkey PRIMARY KEY (id);
ALTER TABLE ONLY issue_links
ADD CONSTRAINT issue_links_pkey PRIMARY KEY (id);
@ -20349,6 +20372,8 @@ CREATE UNIQUE INDEX index_issue_assignees_on_issue_id_and_user_id ON issue_assig
CREATE INDEX index_issue_assignees_on_user_id ON issue_assignees USING btree (user_id);
CREATE UNIQUE INDEX index_issue_email_participants_on_issue_id_and_email ON issue_email_participants USING btree (issue_id, email);
CREATE INDEX index_issue_links_on_source_id ON issue_links USING btree (source_id);
CREATE UNIQUE INDEX index_issue_links_on_source_id_and_target_id ON issue_links USING btree (source_id, target_id);
@ -22621,6 +22646,9 @@ ALTER TABLE ONLY user_synced_attributes_metadata
ALTER TABLE ONLY project_authorizations
ADD CONSTRAINT fk_rails_0f84bb11f3 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY issue_email_participants
ADD CONSTRAINT fk_rails_0fdfd8b811 FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE;
ALTER TABLE ONLY merge_request_context_commits
ADD CONSTRAINT fk_rails_0fe0039f60 FOREIGN KEY (merge_request_id) REFERENCES merge_requests(id) ON DELETE CASCADE;

View file

@ -24,6 +24,7 @@
- [Background migrations](../background_migrations.md)
- [Swapping tables](../swapping_tables.md)
- [Deleting migrations](../deleting_migrations.md)
- [Partitioning tables](table_partitioning.md)
## Debugging

View file

@ -0,0 +1,253 @@
# Database table partitioning
Table partitioning is a powerful database feature that allows a table's
data to be split into smaller physical tables that act as a single large
table. If the application is designed to work with partitioning in mind,
there can be multiple benefits, such as:
- Query performance can be improved greatly, because the database can
cheaply eliminate much of the data from the search space, while still
providing full SQL capabilities.
- Bulk deletes can be achieved with minimal impact on the database by
dropping entire partitions. This is a natural fit for features that need
to periodically delete data that falls outside the retention window.
- Administrative tasks like `VACUUM` and index rebuilds can operate on
individual partitions, rather than across a single massive table.
Unfortunately, not all models fit a partitioning scheme, and there are
significant drawbacks if implemented incorrectly. Additionally, tables
can only be partitioned at their creation, making it nontrivial to apply
partitioning to a busy database. A suite of migration tools are available
to enable backend developers to partition existing tables, but the
migration process is rather heavy, taking multiple steps split across
several releases. Due to the limitations of partitioning and the related
migrations, you should understand how partitioning fits your use case
before attempting to leverage this feature.
## Determining when to use partitioning
While partitioning can be very useful when properly applied, it's
imperative to identify if the data and workload of a table naturally fit a
partitioning scheme. There are a few details you'll have to understand
in order to decide if partitioning is a good fit for your particular
problem.
First, a table is partitioned on a partition key, which is a column or
set of columns which determine how the data will be split across the
partitions. The partition key is used by the database when reading or
writing data, to decide which partition(s) need to be accessed. The
partition key should be a column that would be included in a `WHERE`
clause on almost all queries accessing that table.
Second, it's necessary to understand the strategy the database will
use to split the data across the partitions. The scheme supported by the
GitLab migration helpers is date-range partitioning, where each partition
in the table contains data for a single month. In this case, the partitioning
key would need to be a timestamp or date column. In order for this type of
partitioning to work well, most queries would need to access data within a
certain date range.
For a more concrete example, the `audit_events` table can be used, which
was the first table to be partitioned in the application database
(scheduled for deployment with the GitLab 13.5 release). This
table tracks audit entries of security events that happen in the
application. In almost all cases, users want to see audit activity that
occurs in a certain timeframe. As a result, date-range partitioning
was a natural fit for how the data would be accessed.
To look at this in more detail, imagine a simplified `audit_events` schema:
```sql
CREATE TABLE audit_events (
id SERIAL NOT NULL PRIMARY KEY,
author_id INT NOT NULL,
details jsonb NOT NULL,
created_at timestamptz NOT NULL);
```
Now imagine typical queries in the UI would display the data within a
certain date range, like a single week:
```sql
SELECT *
FROM audit_events
WHERE created_at >= '2020-01-01 00:00:00'
AND created_at < '2020-01-08 00:00:00'
ORDER BY created_at DESC
LIMIT 100
```
If the table is partioned on the `created_at` column the base table would
look like:
```sql
CREATE TABLE audit_events (
id SERIAL NOT NULL,
author_id INT NOT NULL,
details jsonb NOT NULL,
created_at timestamptz NOT NULL,
PRIMARY KEY (id, created_at))
PARTITION BY RANGE(created_at);
```
NOTE: **Note:**
The primary key of a partitioned table must include the partition key as
part of the primary key definition.
And we might have a list of partitions for the table, such as:
```sql
audit_events_202001 FOR VALUES FROM ('2020-01-01') TO ('2020-02-01')
audit_events_202002 FOR VALUES FROM ('2020-02-01') TO ('2020-03-01')
audit_events_202003 FOR VALUES FROM ('2020-03-01') TO ('2020-04-01')
```
Each partition is a separate physical table, with the same structure as
the base `audit_events` table, but contains only data for rows where the
partition key falls in the specified range. For example, the partition
`audit_events_202001` contains rows where the `created_at` column is
greater than or equal to `2020-01-01` and less than `2020-02-01`.
Now, if we look at the previous example query again, the database can
use the `WHERE` to recognize that all matching rows will be in the
`audit_events_202001` partition. Rather than searching all of the data
in all of the partitions, it can search only the single month's worth
of data in the appropriate partition. In a large table, this can
dramatically reduce the amount of data the database needs to access.
However, imagine a query that does not filter based on the partitioning
key, such as:
```sql
SELECT *
FROM audit_events
WHERE author_id = 123
ORDER BY created_at DESC
LIMIT 100
```
In this example, the database can't prune any partitions from the search,
because matching data could exist in any of them. As a result, it has to
query each partition individually, and aggregate the rows into a single result
set. Since `author_id` would be indexed, the performance impact could
likely be acceptable, but on more complex queries the overhead can be
substantial. Partitioning should only be leveraged if the access patterns
of the data support the partitioning strategy, otherwise performance will
suffer.
## Partitioning a table
Unfortunately, tables can only be partitioned at their creation, making
it nontrivial to apply to a busy database. A suite of migration
tools have been developed to enable backend developers to partition
existing tables. This migration process takes multiple steps which must
be split across several releases.
### Caveats
The partitioning migration helpers work by creating a partitioned duplicate
of the original table and using a combination of a trigger and a background
migration to copy data into the new table. Changes to the original table
schema can be made in parallel with the partitioning migration, but they
must take care to not break the underlying mechanism that makes the migration
work. For example, if a column is added to the table that is being
partitioned, both the partitioned table and the trigger definition need to
be updated to match.
### Step 1: Creating the partitioned copy (Release N)
The first step is to add a migration to create the partitioned copy of
the original table. This migration will also create the appropriate
partitions based on the data in the original table, and install a
trigger that will sync writes from the original table into the
partitioned copy.
An example migration of partitioning the `audit_events` table by its
`created_at` column would look like:
```ruby
class PartitionAuditEvents < ActiveRecord::Migration[6.0]
include Gitlab::Database::PartitioningMigrationHelpers
def up
partition_table_by_date :audit_events, :created_at
end
def down
drop_partitioned_table_for :audit_events
end
end
```
Once this has executed, any inserts, updates or deletes in the
original table will also be duplicated in the new table. For updates and
deletes, the operation will only have an effect if the corresponding row
exists in the partitioned table.
### Step 2: Backfill the partitioned copy (Release N)
The second step is to add a post-deployment migration that will schedule
the background jobs that will backfill existing data from the original table
into the partitioned copy.
Continuing the above example, the migration would look like:
```ruby
class BackfillPartitionAuditEvents < ActiveRecord::Migration[6.0]
include Gitlab::Database::PartitioningMigrationHelpers
def up
enqueue_partitioning_data_migration :audit_events
end
def down
cleanup_partitioning_data_migration :audit_events
end
end
```
This step uses the same mechanism as any background migration, so you
may want to read the [Background Migration](../background_migrations.md)
guide for details on that process. Background jobs are scheduled every
2 minutes and copy `50_000` records at a time, which can be used to
estimate the timing of the background migration portion of the
partitioning migration.
### Step 3: Post-backfill cleanup (Release N+1)
The third step must occur at least one release after the release that
includes the background migration. This gives time for the background
migration to execute properly in self-managed installations. In this step,
add another post-deployment migration that will cleanup after the
background migration. This includes forcing any remaining jobs to
execute, and copying data that may have been missed, due to dropped or
failed jobs.
Once again, continuing the example, this migration would look like:
```ruby
class CleanupPartitionedAuditEventsBackfill < ActiveRecord::Migration[6.0]
include Gitlab::Database::PartitioningMigrationHelpers
def up
finalize_backfilling_partitioned_table :audit_events
end
def down
# no op
end
end
```
After this migration has completed, the original table and partitioned
table should contain identical data. The trigger installed on the
original table guarantees that the data will remain in sync going
forward.
### Step 4: Swap the partitioned and non-partitioned tables (Release N+1)
The final step of the migration will make the partitioned table ready
for use by the application. This section will be updated when the
migration helper is ready, for now development can be followed in the
[Tracking Issue](https://gitlab.com/gitlab-org/gitlab/-/issues/241267).

View file

@ -75,38 +75,23 @@ A feature category can be specified on an entire controller
using:
```ruby
class Projects::MergeRequestsController < ApplicationController
feature_category :source_code_management
class Boards::ListsController < ApplicationController
feature_category :kanban_boards
end
```
The feature category can be limited to a list of actions using the
`only` argument, actions can be excluded using the `except` argument.
second argument:
```ruby
class Projects::MergeRequestsController < ApplicationController
feature_category :code_testing, only: [:metrics_reports]
feature_category :source_code_management, except: [:test_reports, :coverage_reports]
class DashboardController < ApplicationController
feature_category :issue_tracking, [:issues, :issues_calendar]
feature_category :code_review, [:merge_requests]
end
```
`except` and `only` arguments can not be combined.
When specifying `except` all other actions will get the specified
category assigned.
The assignment can also be scoped using `if` and `unless` procs:
```ruby
class Projects::MergeRequestsController < ApplicationController
feature_category :source_code_management,
unless: -> (action) { action.include?("reports") }
if: -> (action) { action.include?("widget") }
end
```
In this case, both procs need to be satisfied for the action to get
the category assigned.
These forms cannot be mixed: if a controller has more than one category,
every single action must be listed.
### Excluding controller actions from feature categorization
@ -125,6 +110,5 @@ The `spec/controllers/every_controller_spec.rb` will iterate over all
defined routes, and check the controller to see if a category is
assigned to all actions.
The spec also validates if the used feature categories are known. And
if the actions used in `only` and `except` configuration still exist
as routes.
The spec also validates if the used feature categories are known. And if
the actions used in configuration still exist as routes.

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -122,6 +122,8 @@ the commits to open the single-commit view. From there, you can navigate among t
by clicking the **Prev** and **Next** buttons on the top-right of the page or by using the
<kbd>X</kbd> and <kbd>C</kbd> keyboard shortcuts.
![Merge requests commit navigation](img/commit_nav_v13_4.png)
### Incrementally expand merge request diffs
By default, the diff shows only the parts of a file which are changed.

View file

@ -141,6 +141,7 @@ module Gitlab
projects_creating_incidents: distinct_count(Issue.incident, :project_id),
projects_imported_from_github: count(Project.where(import_type: 'github')),
projects_with_repositories_enabled: count(ProjectFeature.where('repository_access_level > ?', ProjectFeature::DISABLED)),
projects_with_tracing_enabled: count(ProjectTracingSetting),
projects_with_error_tracking_enabled: count(::ErrorTracking::ProjectErrorTrackingSetting.where(enabled: true)),
projects_with_alerts_service_enabled: count(AlertsService.active),
projects_with_prometheus_alerts: distinct_count(PrometheusAlert, :project_id),
@ -573,7 +574,8 @@ module Gitlab
clusters_applications_prometheus: cluster_applications_user_distinct_count(::Clusters::Applications::Prometheus, time_period),
operations_dashboard_default_dashboard: count(::User.active.with_dashboard('operations').where(time_period),
start: user_minimum_id,
finish: user_maximum_id)
finish: user_maximum_id),
projects_with_tracing_enabled: distinct_count(::Project.with_tracing_enabled.where(time_period), :creator_id)
}
end
# rubocop: enable CodeReuse/ActiveRecord

View file

@ -265,6 +265,11 @@ msgid_plural "%d open issues"
msgstr[0] ""
msgstr[1] ""
msgid "%d pending comment"
msgid_plural "%d pending comments"
msgstr[0] ""
msgstr[1] ""
msgid "%d personal project will be removed and cannot be restored."
msgid_plural "%d personal projects will be removed and cannot be restored."
msgstr[0] ""
@ -424,11 +429,6 @@ msgid_plural "%{count} participants"
msgstr[0] ""
msgstr[1] ""
msgid "%{count} pending comment"
msgid_plural "%{count} pending comments"
msgstr[0] ""
msgstr[1] ""
msgid "%{count} related %{pluralized_subject}: %{links}"
msgstr ""
@ -3476,12 +3476,6 @@ msgstr ""
msgid "Artifacts"
msgstr ""
msgid "As U2F devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a U2F device. That way you'll always be able to log in - even when you're using an unsupported browser."
msgstr ""
msgid "As WebAuthn devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a WebAuthn device. That way you'll always be able to log in - even when you're using an unsupported browser."
msgstr ""
msgid "As we continue to build more features for SAST, we'd love your feedback on the SAST configuration feature in %{linkStart}this issue%{linkEnd}."
msgstr ""
@ -3987,15 +3981,6 @@ msgstr ""
msgid "BambooService|You must set up automatic revision labeling and a repository trigger in Bamboo."
msgstr ""
msgid "BatchComments|Delete all pending comments"
msgstr ""
msgid "BatchComments|Discard review?"
msgstr ""
msgid "BatchComments|You're about to discard your review which will delete all of your pending comments. The deleted comments %{strong_start}cannot%{strong_end} be restored."
msgstr ""
msgid "Be careful. Changing the project's namespace can have unintended side effects."
msgstr ""
@ -6989,6 +6974,9 @@ msgstr[1] ""
msgid "ContainerRegistry|Set cleanup policy"
msgstr ""
msgid "ContainerRegistry|Some tags were not deleted"
msgstr ""
msgid "ContainerRegistry|Something went wrong while fetching the cleanup policy."
msgstr ""
@ -7028,6 +7016,9 @@ msgstr ""
msgid "ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}expire:%{italicEnd}"
msgstr ""
msgid "ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}manually run cleanup now%{adminLinkEnd} or you can wait for the cleanup policy to automatically run again. %{docLinkStart}More information%{docLinkEnd}"
msgstr ""
msgid "ContainerRegistry|The last tag related to this image was recently removed. This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process. If you have any questions, contact your administrator."
msgstr ""
@ -9069,9 +9060,6 @@ msgstr ""
msgid "Discard draft"
msgstr ""
msgid "Discard review"
msgstr ""
msgid "DiscordService|Discord Notifications"
msgstr ""
@ -11323,9 +11311,6 @@ msgstr ""
msgid "Finish editing this message first!"
msgstr ""
msgid "Finish review"
msgstr ""
msgid "Finish setting up your dedicated account for %{group_name}."
msgstr ""
@ -17544,6 +17529,12 @@ msgstr ""
msgid "Not Implemented"
msgstr ""
msgid "Not all browsers support U2F devices. Therefore, we require that you set up a two-factor authentication app first. That way you'll always be able to sign in - even when you're using an unsupported browser."
msgstr ""
msgid "Not all browsers support WebAuthn. Therefore, we require that you set up a two-factor authentication app first. That way you'll always be able to sign in - even from an unsupported browser."
msgstr ""
msgid "Not all data has been processed yet, the accuracy of the chart for the selected timeframe is limited."
msgstr ""
@ -18578,6 +18569,9 @@ msgstr ""
msgid "Pending"
msgstr ""
msgid "Pending comments"
msgstr ""
msgid "People without permission will never get a notification and won't be able to comment."
msgstr ""
@ -21827,6 +21821,9 @@ msgstr ""
msgid "Requests to these domain(s)/address(es) on the local network will be allowed when local requests from hooks and services are not allowed. IP ranges such as 1:0:0:0:0:0:0:0/124 or 127.0.0.0/28 are supported. Domain wildcards are not supported currently. Use comma, semicolon, or newline to separate multiple entries. The allowlist can hold a maximum of 1000 entries. Domains should use IDNA encoding. Ex: example.com, 192.168.1.1, 127.0.0.0/28, xn--itlab-j1a.com."
msgstr ""
msgid "Require admin approval for new sign-ups"
msgstr ""
msgid "Require all users in this group to setup Two-factor authentication"
msgstr ""
@ -23465,6 +23462,9 @@ msgstr ""
msgid "Set up a %{type} Runner manually"
msgstr ""
msgid "Set up a hardware device as a second factor to sign in."
msgstr ""
msgid "Set up assertions/attributes/claims (email, first_name, last_name) and NameID according to %{docsLinkStart}the documentation %{icon}%{docsLinkEnd}"
msgstr ""
@ -27920,9 +27920,6 @@ msgstr ""
msgid "Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab"
msgstr ""
msgid "Use a hardware device to add the second factor of authentication."
msgstr ""
msgid "Use an one time password authenticator on your mobile device or computer to enable two-factor authentication (2FA)."
msgstr ""
@ -28950,6 +28947,9 @@ msgstr ""
msgid "When a runner is locked, it cannot be assigned to other projects"
msgstr ""
msgid "When enabled, any user visiting %{host} and creating an account will have to be explicitly approved by the admin before they can login. This setting is effective only if sign-ups are enabled."
msgstr ""
msgid "When enabled, any user visiting %{host} will be able to create an account."
msgstr ""

View file

@ -75,8 +75,6 @@ module QA
view 'app/assets/javascripts/batch_comments/components/review_bar.vue' do
element :review_bar
element :discard_review
element :modal_delete_pending_comments
end
view 'app/assets/javascripts/notes/components/note_form.vue' do

View file

@ -5,27 +5,39 @@ require 'spec_helper'
RSpec.describe ApplicationCable::Connection, :clean_gitlab_redis_shared_state do
let(:session_id) { Rack::Session::SessionId.new('6919a6f1bb119dd7396fadc38fd18d0d') }
before do
Gitlab::Redis::SharedState.with do |redis|
redis.set("session:gitlab:#{session_id.private_id}", Marshal.dump(session_hash))
context 'when session cookie is set' do
before do
Gitlab::Redis::SharedState.with do |redis|
redis.set("session:gitlab:#{session_id.private_id}", Marshal.dump(session_hash))
end
cookies[Gitlab::Application.config.session_options[:key]] = session_id.public_id
end
cookies[Gitlab::Application.config.session_options[:key]] = session_id.public_id
end
context 'when user is logged in' do
let(:user) { create(:user) }
let(:session_hash) { { 'warden.user.user.key' => [[user.id], user.encrypted_password[0, 29]] } }
context 'when user is logged in' do
let(:user) { create(:user) }
let(:session_hash) { { 'warden.user.user.key' => [[user.id], user.encrypted_password[0, 29]] } }
it 'sets current_user' do
connect
it 'sets current_user' do
connect
expect(connection.current_user).to eq(user)
end
expect(connection.current_user).to eq(user)
context 'with a stale password' do
let(:partial_password_hash) { build(:user, password: 'some_old_password').encrypted_password[0, 29] }
let(:session_hash) { { 'warden.user.user.key' => [[user.id], partial_password_hash] } }
it 'sets current_user to nil' do
connect
expect(connection.current_user).to be_nil
end
end
end
context 'with a stale password' do
let(:partial_password_hash) { build(:user, password: 'some_old_password').encrypted_password[0, 29] }
let(:session_hash) { { 'warden.user.user.key' => [[user.id], partial_password_hash] } }
context 'when user is not logged in' do
let(:session_hash) { {} }
it 'sets current_user to nil' do
connect
@ -35,13 +47,21 @@ RSpec.describe ApplicationCable::Connection, :clean_gitlab_redis_shared_state do
end
end
context 'when user is not logged in' do
let(:session_hash) { {} }
context 'when session cookie is not set' do
it 'sets current_user to nil' do
connect
expect(connection.current_user).to be_nil
end
end
context 'when session cookie is an empty string' do
it 'sets current_user to nil' do
cookies[Gitlab::Application.config.session_options[:key]] = ''
connect
expect(connection.current_user).to be_nil
end
end
end

View file

@ -87,6 +87,38 @@ RSpec.describe Admin::ApplicationSettingsController do
sign_in(admin)
end
context 'require_admin_approval_after_user_signup setting' do
subject do
put :update, params: { application_setting: { require_admin_approval_after_user_signup: true } }
end
context 'when feature is enabled' do
before do
stub_feature_flags(admin_approval_for_new_user_signups: true)
end
it 'updates the require_admin_approval_after_user_signup setting' do
subject
expect(response).to redirect_to(general_admin_application_settings_path)
expect(ApplicationSetting.current.require_admin_approval_after_user_signup).to eq(true)
end
end
context 'when feature is disabled' do
before do
stub_feature_flags(admin_approval_for_new_user_signups: false)
end
it 'does not update the require_admin_approval_after_user_signup setting' do
subject
expect(response).to redirect_to(general_admin_application_settings_path)
expect(ApplicationSetting.current.require_admin_approval_after_user_signup).not_to eq(true)
end
end
end
it 'updates the password_authentication_enabled_for_git setting' do
put :update, params: { application_setting: { password_authentication_enabled_for_git: "0" } }

View file

@ -109,7 +109,7 @@ RSpec.describe Admin::SessionsController, :do_not_mock_admin_mode do
# triggering the auth form will request admin mode
get :new
Timecop.freeze(Gitlab::Auth::CurrentUserMode::ADMIN_MODE_REQUESTED_GRACE_PERIOD.from_now) do
travel_to(Gitlab::Auth::CurrentUserMode::ADMIN_MODE_REQUESTED_GRACE_PERIOD.from_now) do
post :create, params: { user: { password: user.password } }
expect(response).to redirect_to(new_admin_session_path)

View file

@ -416,13 +416,13 @@ RSpec.describe ApplicationController do
end
it 'returns false if the grace period has expired' do
Timecop.freeze(3.hours.from_now) do
travel_to(3.hours.from_now) do
expect(subject).to be_falsey
end
end
it 'returns true if the grace period is still active' do
Timecop.freeze(1.hour.from_now) do
travel_to(1.hour.from_now) do
expect(subject).to be_truthy
end
end

View file

@ -1,53 +0,0 @@
# frozen_string_literal: true
require "fast_spec_helper"
require "rspec-parameterized"
require_relative "../../../../app/controllers/concerns/controller_with_feature_category/config"
RSpec.describe ControllerWithFeatureCategory::Config do
describe "#matches?" do
using RSpec::Parameterized::TableSyntax
where(:only_actions, :except_actions, :if_proc, :unless_proc, :test_action, :expected) do
nil | nil | nil | nil | "action" | true
[:included] | nil | nil | nil | "action" | false
[:included] | nil | nil | nil | "included" | true
nil | [:excluded] | nil | nil | "excluded" | false
nil | nil | true | nil | "action" | true
[:included] | nil | true | nil | "action" | false
[:included] | nil | true | nil | "included" | true
nil | [:excluded] | true | nil | "excluded" | false
nil | nil | false | nil | "action" | false
[:included] | nil | false | nil | "action" | false
[:included] | nil | false | nil | "included" | false
nil | [:excluded] | false | nil | "excluded" | false
nil | nil | nil | true | "action" | false
[:included] | nil | nil | true | "action" | false
[:included] | nil | nil | true | "included" | false
nil | [:excluded] | nil | true | "excluded" | false
nil | nil | nil | false | "action" | true
[:included] | nil | nil | false | "action" | false
[:included] | nil | nil | false | "included" | true
nil | [:excluded] | nil | false | "excluded" | false
nil | nil | true | false | "action" | true
[:included] | nil | true | false | "action" | false
[:included] | nil | true | false | "included" | true
nil | [:excluded] | true | false | "excluded" | false
nil | nil | false | true | "action" | false
[:included] | nil | false | true | "action" | false
[:included] | nil | false | true | "included" | false
nil | [:excluded] | false | true | "excluded" | false
end
with_them do
let(:config) do
if_to_proc = if_proc.nil? ? nil : -> (_) { if_proc }
unless_to_proc = unless_proc.nil? ? nil : -> (_) { unless_proc }
described_class.new(:category, only_actions, except_actions, if_to_proc, unless_to_proc)
end
specify { expect(config.matches?(test_action)).to be(expected) }
end
end
end

View file

@ -2,7 +2,6 @@
require 'fast_spec_helper'
require_relative "../../../app/controllers/concerns/controller_with_feature_category"
require_relative "../../../app/controllers/concerns/controller_with_feature_category/config"
RSpec.describe ControllerWithFeatureCategory do
describe ".feature_category_for_action" do
@ -14,17 +13,15 @@ RSpec.describe ControllerWithFeatureCategory do
let(:controller) do
Class.new(base_controller) do
feature_category :baz
feature_category :foo, except: %w(update edit)
feature_category :bar, only: %w(index show)
feature_category :quux, only: %w(destroy)
feature_category :quuz, only: %w(destroy)
feature_category :foo, %w(update edit)
feature_category :bar, %w(index show)
feature_category :quux, %w(destroy)
end
end
let(:subclass) do
Class.new(controller) do
feature_category :qux, only: %w(index)
feature_category :baz, %w(subclass_index)
end
end
@ -33,34 +30,31 @@ RSpec.describe ControllerWithFeatureCategory do
end
it "returns the expected category", :aggregate_failures do
expect(controller.feature_category_for_action("update")).to eq(:baz)
expect(controller.feature_category_for_action("hello")).to eq(:foo)
expect(controller.feature_category_for_action("update")).to eq(:foo)
expect(controller.feature_category_for_action("index")).to eq(:bar)
expect(controller.feature_category_for_action("destroy")).to eq(:quux)
end
it "returns the closest match for categories defined in subclasses" do
expect(subclass.feature_category_for_action("index")).to eq(:qux)
expect(subclass.feature_category_for_action("show")).to eq(:bar)
it "returns the expected category for categories defined in subclasses" do
expect(subclass.feature_category_for_action("subclass_index")).to eq(:baz)
end
it "returns the last defined feature category when multiple match" do
expect(controller.feature_category_for_action("destroy")).to eq(:quuz)
end
it "raises an error when using including and excluding the same action" do
it "raises an error when defining for the controller and for individual actions" do
expect do
Class.new(base_controller) do
feature_category :hello, only: [:world], except: [:world]
feature_category :hello
feature_category :goodbye, [:world]
end
end.to raise_error(%r(cannot configure both `only` and `except`))
end.to raise_error(ArgumentError, "hello is defined for all actions, but other categories are set")
end
it "raises an error when using unknown arguments" do
it "raises an error when multiple calls define the same action" do
expect do
Class.new(base_controller) do
feature_category :hello, hello: :world
feature_category :hello, [:world]
feature_category :goodbye, ["world"]
end
end.to raise_error(%r(unknown arguments))
end.to raise_error(ArgumentError, "Actions have multiple feature categories: world")
end
end
end

View file

@ -17,20 +17,27 @@ RSpec.describe "Every controller" do
.compact
.select { |route| route[:controller].present? && route[:action].present? }
.map { |route| [constantize_controller(route[:controller]), route[:action]] }
.reject { |route| route.first.nil? || !route.first.include?(ControllerWithFeatureCategory) }
.select { |(controller, action)| controller&.include?(ControllerWithFeatureCategory) }
.reject { |(controller, action)| controller == Devise::UnlocksController }
end
let_it_be(:routes_without_category) do
controller_actions.map do |controller, action|
"#{controller}##{action}" unless controller.feature_category_for_action(action)
next if controller.feature_category_for_action(action)
next unless controller.to_s.start_with?('B', 'C', 'D', 'E', 'F', 'Projects::MergeRequestsController')
"#{controller}##{action}"
end.compact
end
it "has feature categories" do
pending("We'll work on defining categories for all controllers: "\
"https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/463")
routes_without_category.map { |x| x.split('#') }.group_by(&:first).each do |controller, actions|
puts controller
puts actions.map { |x| ":#{x.last}" }.sort.join(', ')
puts ''
end
expect(routes_without_category).to be_empty, "#{routes_without_category.first(10)} did not have a category"
expect(routes_without_category).to be_empty, "#{routes_without_category} did not have a category"
end
it "completed controllers don't get new routes without categories" do
@ -74,9 +81,9 @@ RSpec.describe "Every controller" do
end
def actions_defined_in_feature_category_config(controller)
feature_category_configs = controller.send(:class_attributes)[:feature_category_config]
feature_category_configs.map do |config|
Array(config.send(:only)) + Array(config.send(:except))
end.flatten.uniq.map(&:to_s)
controller.send(:class_attributes)[:feature_category_config]
.values
.flatten
.map(&:to_s)
end
end

View file

@ -113,18 +113,6 @@ RSpec.describe Projects::Releases::EvidencesController do
it_behaves_like 'does not show the issue in evidence'
context 'when the issue is confidential' do
let(:issue) { create(:issue, :confidential, project: project) }
it_behaves_like 'does not show the issue in evidence'
end
context 'when the user is the author of the confidential issue' do
let(:issue) { create(:issue, :confidential, project: project, author: user) }
it_behaves_like 'does not show the issue in evidence'
end
context 'when project is private' do
let(:project) { create(:project, :repository, :private) }
@ -143,32 +131,16 @@ RSpec.describe Projects::Releases::EvidencesController do
it_behaves_like 'does not show the issue in evidence'
context 'when the issue is confidential' do
let(:issue) { create(:issue, :confidential, project: project) }
it_behaves_like 'does not show the issue in evidence'
end
context 'when the user is the author of the confidential issue' do
let(:issue) { create(:issue, :confidential, project: project, author: user) }
it_behaves_like 'does not show the issue in evidence'
end
context 'when project is private' do
let(:project) { create(:project, :repository, :private) }
it 'returns evidence ' do
subject
expect(json_response).to eq(evidence.summary)
end
it_behaves_like 'does not show the issue in evidence'
end
context 'when project restricts the visibility of issues to project members only' do
let(:project) { create(:project, :repository, :issues_private) }
it_behaves_like 'evidence not found'
it_behaves_like 'does not show the issue in evidence'
end
end

View file

@ -131,4 +131,25 @@ RSpec.describe Projects::TagsController do
end
end
end
describe 'DELETE #destroy' do
let(:tag) { project.repository.add_tag(user, 'fake-tag', 'master') }
let(:request) do
delete(:destroy, params: { id: tag.name, namespace_id: project.namespace.to_param, project_id: project })
end
before do
project.add_developer(user)
sign_in(user)
end
it 'deletes tag' do
request
expect(response).to be_successful
expect(response.body).to include("Tag was removed")
expect(project.repository.find_tag(tag.name)).not_to be_present
end
end
end

View file

@ -78,6 +78,9 @@ RSpec.describe SessionsController do
end
context 'when using standard authentications' do
let(:user) { create(:user) }
let(:post_action) { post(:create, params: { user: { login: user.username, password: user.password } }) }
context 'invalid password' do
it 'does not authenticate user' do
post(:create, params: { user: { login: 'invalid', password: 'invalid' } })
@ -87,6 +90,26 @@ RSpec.describe SessionsController do
end
end
context 'a blocked user' do
it 'does not authenticate the user' do
user.block!
post_action
expect(@request.env['warden']).not_to be_authenticated
expect(flash[:alert]).to include('Your account has been blocked')
end
end
context 'an internal user' do
it 'does not authenticate the user' do
user.ghost!
post_action
expect(@request.env['warden']).not_to be_authenticated
expect(flash[:alert]).to include('Your account does not have the required permission to login')
end
end
context 'when using valid password', :clean_gitlab_redis_shared_state do
let(:user) { create(:user) }
let(:user_params) { { login: user.username, password: user.password } }

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
FactoryBot.define do
factory :issue_email_participant do
issue
email { generate(:email) }
end
end

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
FactoryBot.define do
factory :project_tracing_setting do
project
external_url { 'https://example.com' }
end
end

View file

@ -50,6 +50,9 @@ FactoryBot.define do
create(:protected_branch, project: projects[0])
create(:protected_branch, name: 'main', project: projects[0])
# Tracing
create(:project_tracing_setting, project: projects[0])
# Incident Labeled Issues
incident_label = create(:label, :incident, project: projects[0])
create(:labeled_issue, project: projects[0], labels: [incident_label])

View file

@ -130,6 +130,38 @@ RSpec.describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_n
expect(user_internal_regex['placeholder']).to eq 'Regex pattern'
end
context 'Change Sign-up restrictions' do
context 'Require Admin approval for new signup setting' do
context 'when feature is enabled' do
before do
stub_feature_flags(admin_approval_for_new_user_signups: true)
end
it 'changes the setting' do
page.within('.as-signup') do
check 'Require admin approval for new sign-ups'
click_button 'Save changes'
end
expect(current_settings.require_admin_approval_after_user_signup).to be_truthy
expect(page).to have_content "Application settings saved successfully"
end
end
context 'when feature is disabled' do
before do
stub_feature_flags(admin_approval_for_new_user_signups: false)
end
it 'does not show the the setting' do
page.within('.as-signup') do
expect(page).not_to have_selector('.application_setting_require_admin_approval_after_user_signup')
end
end
end
end
end
it 'Change Sign-in restrictions' do
page.within('.as-signin') do
fill_in 'Home page URL', with: 'https://about.gitlab.com/'

View file

@ -180,7 +180,7 @@ RSpec.describe 'Contributions Calendar', :js do
before do
push_code_contribution
Timecop.freeze(Date.yesterday) do
travel_to(Date.yesterday) do
Issues::CreateService.new(contributed_project, user, issue_params).execute
end
end

View file

@ -41,7 +41,6 @@ RSpec.describe 'Merge request > Batch comments', :js do
write_comment
page.within('.review-bar-content') do
click_button 'Finish review'
click_button 'Submit review'
end
@ -64,18 +63,6 @@ RSpec.describe 'Merge request > Batch comments', :js do
expect(page).to have_selector('.note:not(.draft-note)', text: 'Line is wrong')
end
it 'discards review' do
write_comment
click_button 'Discard review'
click_button 'Delete all pending comments'
wait_for_requests
expect(page).not_to have_selector('.draft-note-component')
end
it 'deletes draft note' do
write_comment
@ -149,7 +136,6 @@ RSpec.describe 'Merge request > Batch comments', :js do
write_reply_to_discussion(resolve: true)
page.within('.review-bar-content') do
click_button 'Finish review'
click_button 'Submit review'
end
@ -192,7 +178,6 @@ RSpec.describe 'Merge request > Batch comments', :js do
write_reply_to_discussion(button_text: 'Start a review', unresolve: true)
page.within('.review-bar-content') do
click_button 'Finish review'
click_button 'Submit review'
end

View file

@ -21,11 +21,11 @@ RSpec.describe 'Branches' do
before do
# Add 4 stale branches
(1..4).reverse_each do |i|
Timecop.freeze((threshold + i).ago) { create_file(message: "a commit in stale-#{i}", branch_name: "stale-#{i}") }
travel_to((threshold + i).ago) { create_file(message: "a commit in stale-#{i}", branch_name: "stale-#{i}") }
end
# Add 6 active branches
(1..6).each do |i|
Timecop.freeze((threshold - i).ago) { create_file(message: "a commit in active-#{i}", branch_name: "active-#{i}") }
travel_to((threshold - i).ago) { create_file(message: "a commit in active-#{i}", branch_name: "active-#{i}") }
end
end

Some files were not shown because too many files have changed in this diff Show more