Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-09-02 03:09:04 +00:00
parent 61a1ecc5e9
commit ceb0c326ae
48 changed files with 701 additions and 4340 deletions

View File

@ -1,61 +0,0 @@
<script>
// This component is being replaced in favor of './board_card.vue' for GraphQL boards
import sidebarEventHub from '~/sidebar/event_hub';
import eventHub from '../eventhub';
import boardsStore from '../stores/boards_store';
import BoardCardLayoutDeprecated from './board_card_layout_deprecated.vue';
export default {
components: {
BoardCardLayout: BoardCardLayoutDeprecated,
},
props: {
list: {
type: Object,
default: () => ({}),
required: false,
},
issue: {
type: Object,
default: () => ({}),
required: false,
},
},
methods: {
// These are methods instead of computed's, because boardsStore is not reactive.
isActive() {
return this.getActiveId() === this.issue.id;
},
getActiveId() {
return boardsStore.detail?.issue?.id;
},
showIssue({ isMultiSelect }) {
// If no issues are opened, close all sidebars first
if (!this.getActiveId()) {
sidebarEventHub.$emit('sidebar.closeAll');
}
if (this.isActive()) {
eventHub.$emit('clearDetailIssue', isMultiSelect);
if (isMultiSelect) {
eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
}
} else {
eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
boardsStore.setListDetail(this.list);
}
},
},
};
</script>
<template>
<board-card-layout
data-qa-selector="board_card"
:issue="issue"
:list="list"
:is-active="isActive()"
v-bind="$attrs"
@show="showIssue"
/>
</template>

View File

@ -1,91 +0,0 @@
<script>
import { mapActions, mapGetters } from 'vuex';
import { ISSUABLE } from '~/boards/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import boardsStore from '../stores/boards_store';
import IssueCardInnerDeprecated from './issue_card_inner_deprecated.vue';
export default {
name: 'BoardCardLayout',
components: {
IssueCardInner: IssueCardInnerDeprecated,
},
mixins: [glFeatureFlagMixin()],
props: {
list: {
type: Object,
default: () => ({}),
required: false,
},
issue: {
type: Object,
default: () => ({}),
required: false,
},
disabled: {
type: Boolean,
default: false,
required: false,
},
index: {
type: Number,
default: 0,
required: false,
},
isActive: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
showDetail: false,
multiSelect: boardsStore.multiSelect,
};
},
computed: {
...mapGetters(['isSwimlanesOn']),
multiSelectVisible() {
return this.multiSelect.list.findIndex((issue) => issue.id === this.issue.id) > -1;
},
},
methods: {
...mapActions(['setActiveId']),
mouseDown() {
this.showDetail = true;
},
mouseMove() {
this.showDetail = false;
},
showIssue(e) {
// Don't do anything if this happened on a no trigger element
if (e.target.classList.contains('js-no-trigger')) return;
this.setActiveId({ id: this.issue.id, sidebarType: ISSUABLE });
},
},
};
</script>
<template>
<li
:class="{
'multi-select': multiSelectVisible,
'user-can-drag': !disabled && issue.id,
'is-disabled': disabled || !issue.id,
'is-active': isActive,
}"
:index="index"
:data-issue-id="issue.id"
:data-issue-iid="issue.iid"
:data-issue-path="issue.referencePath"
data-testid="board_card"
class="board-card gl-p-5 gl-rounded-base"
@mousedown="mouseDown"
@mousemove="mouseMove"
@mouseup="showIssue($event)"
>
<issue-card-inner :list="list" :issue="issue" :update-filters="true" />
</li>
</template>

View File

@ -1,112 +0,0 @@
<script>
// This component is being replaced in favor of './board_column.vue' for GraphQL boards
import Sortable from 'sortablejs';
import BoardListHeader from 'ee_else_ce/boards/components/board_list_header_deprecated.vue';
import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options';
import boardsStore from '../stores/boards_store';
import BoardList from './board_list_deprecated.vue';
export default {
components: {
BoardListHeader,
BoardList,
},
inject: {
boardId: {
default: '',
},
},
props: {
list: {
type: Object,
default: () => ({}),
required: false,
},
disabled: {
type: Boolean,
required: true,
},
},
data() {
return {
detailIssue: boardsStore.detail,
filter: boardsStore.filter,
};
},
computed: {
listIssues() {
return this.list.issues;
},
},
watch: {
filter: {
handler() {
// eslint-disable-next-line vue/no-mutating-props
this.list.page = 1;
this.list.getIssues(true).catch(() => {
// TODO: handle request error
});
},
deep: true,
},
'list.highlighted': {
handler(highlighted) {
if (highlighted) {
this.$nextTick(() => {
this.$el.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
},
immediate: true,
},
},
mounted() {
const instance = this;
const sortableOptions = getBoardSortableDefaultOptions({
disabled: this.disabled,
group: 'boards',
draggable: '.is-draggable',
handle: '.js-board-handle',
onEnd(e) {
sortableEnd();
const sortable = this;
if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) {
const order = sortable.toArray();
const list = boardsStore.findList('id', parseInt(e.item.dataset.id, 10));
instance.$nextTick(() => {
boardsStore.moveList(list, order);
});
}
},
});
Sortable.create(this.$el.parentNode, sortableOptions);
},
};
</script>
<template>
<div
:class="{
'is-draggable': !list.preset,
'is-expandable': list.isExpandable,
'is-collapsed': !list.isExpanded,
'board-type-assignee': list.type === 'assignee',
}"
:data-id="list.id"
class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal"
data-qa-selector="board_list"
>
<div
class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base"
:class="{ 'board-column-highlighted': list.highlighted }"
>
<board-list-header :list="list" :disabled="disabled" />
<board-list ref="board-list" :disabled="disabled" :issues="listIssues" :list="list" />
</div>
</div>
</template>

View File

@ -1,459 +0,0 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { Sortable, MultiDrag } from 'sortablejs';
import createFlash from '~/flash';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { sprintf, __ } from '~/locale';
import eventHub from '../eventhub';
import {
getBoardSortableDefaultOptions,
sortableStart,
sortableEnd,
} from '../mixins/sortable_default_options';
import boardsStore from '../stores/boards_store';
import boardCard from './board_card_deprecated.vue';
import boardNewIssue from './board_new_issue_deprecated.vue';
// This component is being replaced in favor of './board_list.vue' for GraphQL boards
Sortable.mount(new MultiDrag());
export default {
name: 'BoardList',
components: {
boardCard,
boardNewIssue,
GlLoadingIcon,
},
props: {
disabled: {
type: Boolean,
required: true,
},
list: {
type: Object,
required: true,
},
issues: {
type: Array,
required: true,
},
},
data() {
return {
scrollOffset: 250,
filters: boardsStore.state.filters,
showCount: false,
showIssueForm: false,
};
},
computed: {
paginatedIssueText() {
return sprintf(__('Showing %{pageSize} of %{total} issues'), {
pageSize: this.list.issues.length,
total: this.list.issuesSize,
});
},
issuesSizeExceedsMax() {
return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount;
},
loading() {
return this.list.loading;
},
},
watch: {
filters: {
handler() {
// eslint-disable-next-line vue/no-mutating-props
this.list.loadingMore = false;
this.$refs.list.scrollTop = 0;
},
deep: true,
},
issues() {
this.$nextTick(() => {
if (
this.scrollHeight() <= this.listHeight() &&
this.list.issuesSize > this.list.issues.length &&
this.list.isExpanded
) {
// eslint-disable-next-line vue/no-mutating-props
this.list.page += 1;
this.list.getIssues(false).catch(() => {
// TODO: handle request error
});
}
if (this.scrollHeight() > Math.ceil(this.listHeight())) {
this.showCount = true;
} else {
this.showCount = false;
}
});
},
'list.id': {
handler(id) {
if (id) {
eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm);
}
},
},
},
created() {
eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm);
eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
},
mounted() {
const multiSelectOpts = {
multiDrag: true,
selectedClass: 'js-multi-select',
animation: 500,
};
const options = getBoardSortableDefaultOptions({
scroll: true,
disabled: this.disabled,
filter: '.board-list-count, .is-disabled',
dataIdAttr: 'data-issue-id',
removeCloneOnHide: false,
...multiSelectOpts,
group: {
name: 'issues',
/**
* Dynamically determine between which containers
* items can be moved or copied as
* Assignee lists (EE feature) require this behavior
*/
pull: (to, from, dragEl, e) => {
// As per Sortable's docs, `to` should provide
// reference to exact sortable container on which
// we're trying to drag element, but either it is
// a library's bug or our markup structure is too complex
// that `to` never points to correct container
// See https://github.com/RubaXa/Sortable/issues/1037
//
// So we use `e.target` which is always accurate about
// which element we're currently dragging our card upon
// So from there, we can get reference to actual container
// and thus the container type to enable Copy or Move
if (e.target) {
const containerEl =
e.target.closest('.js-board-list') || e.target.querySelector('.js-board-list');
const toBoardType = containerEl.dataset.boardType;
const cloneActions = {
label: ['milestone', 'assignee', 'iteration'],
assignee: ['milestone', 'label', 'iteration'],
milestone: ['label', 'assignee', 'iteration'],
iteration: ['label', 'assignee', 'milestone'],
};
if (toBoardType) {
const fromBoardType = this.list.type;
// For each list we check if the destination list is
// a the list were we should clone the issue
const shouldClone = Object.entries(cloneActions).some(
(entry) => fromBoardType === entry[0] && entry[1].includes(toBoardType),
);
if (shouldClone) {
return 'clone';
}
}
}
return true;
},
revertClone: true,
},
onStart: (e) => {
const card = this.$refs.issue[e.oldIndex];
card.showDetail = false;
const { list } = card;
const issue = list.findIssue(Number(e.item.dataset.issueId));
boardsStore.startMoving(list, issue);
this.$root.$emit(BV_HIDE_TOOLTIP);
sortableStart();
},
onAdd: (e) => {
const { items = [], newIndicies = [] } = e;
if (items.length) {
// Not using e.newIndex here instead taking a min of all
// the newIndicies. Basically we have to find that during
// a drop what is the index we're going to start putting
// all the dropped elements from.
const newIndex = Math.min(...newIndicies.map((obj) => obj.index).filter((i) => i !== -1));
const issues = items.map((item) =>
boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
);
boardsStore.moveMultipleIssuesToList({
listFrom: boardsStore.moving.list,
listTo: this.list,
issues,
newIndex,
});
} else {
boardsStore.moveIssueToList(
boardsStore.moving.list,
this.list,
boardsStore.moving.issue,
e.newIndex,
);
this.$nextTick(() => {
e.item.remove();
});
}
},
onUpdate: (e) => {
const sortedArray = this.sortable.toArray().filter((id) => id !== '-1');
const { items = [], newIndicies = [], oldIndicies = [] } = e;
if (items.length) {
const newIndex = Math.min(...newIndicies.map((obj) => obj.index));
const issues = items.map((item) =>
boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
);
boardsStore.moveMultipleIssuesInList({
list: this.list,
issues,
oldIndicies: oldIndicies.map((obj) => obj.index),
newIndex,
idArray: sortedArray,
});
e.items.forEach((el) => {
Sortable.utils.deselect(el);
});
boardsStore.clearMultiSelect();
return;
}
boardsStore.moveIssueInList(
this.list,
boardsStore.moving.issue,
e.oldIndex,
e.newIndex,
sortedArray,
);
},
onEnd: (e) => {
const { items = [], clones = [], to } = e;
// This is not a multi select operation
if (!items.length && !clones.length) {
sortableEnd();
return;
}
let toList;
if (to) {
const containerEl = to.closest('.js-board-list');
toList = boardsStore.findList('id', Number(containerEl.dataset.board));
}
/**
* onEnd is called irrespective if the cards were moved in the
* same list or the other list. Don't remove items if it's same list.
*/
const isSameList = toList && toList.id === this.list.id;
if (toList && !isSameList && boardsStore.shouldRemoveIssue(this.list, toList)) {
const issues = items.map((item) => this.list.findIssue(Number(item.dataset.issueId)));
if (
issues.filter(Boolean).length &&
!boardsStore.issuesAreContiguous(this.list, issues)
) {
const indexes = [];
const ids = this.list.issues.map((i) => i.id);
issues.forEach((issue) => {
const index = ids.indexOf(issue.id);
if (index > -1) {
indexes.push(index);
}
});
// Descending sort because splice would cause index discrepancy otherwise
const sortedIndexes = indexes.sort((a, b) => (a < b ? 1 : -1));
sortedIndexes.forEach((i) => {
/**
* **setTimeout and splice each element one-by-one in a loop
* is intended.**
*
* The problem here is all the indexes are in the list but are
* non-contiguous. Due to that, when we splice all the indexes,
* at once, Vue -- during a re-render -- is unable to find reference
* nodes and the entire app crashes.
*
* If the indexes are contiguous, this piece of code is not
* executed. If it is, this is a possible regression. Only when
* issue indexes are far apart, this logic should ever kick in.
*/
setTimeout(() => {
// eslint-disable-next-line vue/no-mutating-props
this.list.issues.splice(i, 1);
}, 0);
});
}
}
if (!toList) {
createFlash({
message: __('Something went wrong while performing the action.'),
});
}
if (!isSameList) {
boardsStore.clearMultiSelect();
// Since Vue's list does not re-render the same keyed item, we'll
// remove `multi-select` class to express it's unselected
if (clones && clones.length) {
clones.forEach((el) => el.classList.remove('multi-select'));
}
// Due to some bug which I am unable to figure out
// Sortable does not deselect some pending items from the
// source list.
// We'll just do it forcefully here.
Array.from(document.querySelectorAll('.js-multi-select') || []).forEach((item) => {
Sortable.utils.deselect(item);
});
/**
* SortableJS leaves all the moving items "as is" on the DOM.
* Vue picks up and rehydrates the DOM, but we need to explicity
* remove the "trash" items from the DOM.
*
* This is in parity to the logic on single item move from a list/in
* a list. For reference, look at the implementation of onAdd method.
*/
this.$nextTick(() => {
if (items && items.length) {
items.forEach((item) => {
item.remove();
});
}
});
}
sortableEnd();
},
onMove(e) {
return !e.related.classList.contains('board-list-count');
},
onSelect(e) {
const {
item: { classList },
} = e;
if (
classList &&
classList.contains('js-multi-select') &&
!classList.contains('multi-select')
) {
Sortable.utils.deselect(e.item);
}
},
onDeselect: (e) => {
const {
item: { dataset, classList },
} = e;
if (
classList &&
classList.contains('multi-select') &&
!classList.contains('js-multi-select')
) {
const issue = this.list.findIssue(Number(dataset.issueId));
boardsStore.toggleMultiSelect(issue);
}
},
});
this.sortable = Sortable.create(this.$refs.list, options);
// Scroll event on list to load more
this.$refs.list.addEventListener('scroll', this.onScroll);
},
beforeDestroy() {
eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm);
eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop);
this.$refs.list.removeEventListener('scroll', this.onScroll);
},
methods: {
listHeight() {
return this.$refs.list.getBoundingClientRect().height;
},
scrollHeight() {
return this.$refs.list.scrollHeight;
},
scrollTop() {
return this.$refs.list.scrollTop + this.listHeight();
},
scrollToTop() {
this.$refs.list.scrollTop = 0;
},
loadNextPage() {
const getIssues = this.list.nextPage();
const loadingDone = () => {
// eslint-disable-next-line vue/no-mutating-props
this.list.loadingMore = false;
};
if (getIssues) {
// eslint-disable-next-line vue/no-mutating-props
this.list.loadingMore = true;
getIssues.then(loadingDone).catch(loadingDone);
}
},
toggleForm() {
this.showIssueForm = !this.showIssueForm;
},
onScroll() {
if (!this.list.loadingMore && this.scrollTop() > this.scrollHeight() - this.scrollOffset) {
this.loadNextPage();
}
},
},
};
</script>
<template>
<div
:class="{ 'd-none': !list.isExpanded, 'd-flex flex-column': list.isExpanded }"
class="board-list-component position-relative h-100"
data-qa-selector="board_list_cards_area"
>
<div v-if="loading" class="board-list-loading text-center" :aria-label="__('Loading issues')">
<gl-loading-icon size="sm" />
</div>
<board-new-issue v-if="list.type !== 'closed' && showIssueForm" :list="list" />
<ul
v-show="!loading"
ref="list"
:data-board="list.id"
:data-board-type="list.type"
:class="{ 'is-smaller': showIssueForm, 'bg-danger-100': issuesSizeExceedsMax }"
class="board-list w-100 h-100 list-unstyled mb-0 p-1 js-board-list"
>
<board-card
v-for="(issue, index) in issues"
ref="issue"
:key="issue.id"
:index="index"
:list="list"
:issue="issue"
:disabled="disabled"
/>
<li v-if="showCount" class="board-list-count text-center" data-issue-id="-1">
<gl-loading-icon v-show="list.loadingMore" size="sm" label="Loading more issues" />
<span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span>
<span v-else>{{ paginatedIssueText }}</span>
</li>
</ul>
</div>
</template>

View File

@ -1,361 +0,0 @@
<script>
import {
GlButton,
GlButtonGroup,
GlLabel,
GlTooltip,
GlIcon,
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { n__, s__ } from '~/locale';
import sidebarEventHub from '~/sidebar/event_hub';
import AccessorUtilities from '../../lib/utils/accessor';
import { inactiveId, LIST, ListType } from '../constants';
import eventHub from '../eventhub';
import boardsStore from '../stores/boards_store';
import IssueCount from './item_count.vue';
// This component is being replaced in favor of './board_list_header.vue' for GraphQL boards
export default {
components: {
GlButtonGroup,
GlButton,
GlLabel,
GlTooltip,
GlIcon,
GlSprintf,
IssueCount,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: {
currentUserId: {
default: null,
},
boardId: {
default: '',
},
},
props: {
list: {
type: Object,
default: () => ({}),
required: false,
},
disabled: {
type: Boolean,
required: true,
},
isSwimlanesHeader: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
weightFeatureAvailable: false,
};
},
computed: {
...mapState(['activeId']),
isLoggedIn() {
return Boolean(this.currentUserId);
},
listType() {
return this.list.type;
},
listAssignee() {
return this.list?.assignee?.username || '';
},
listTitle() {
return this.list?.label?.description || this.list.title || '';
},
showListHeaderButton() {
return !this.disabled && this.listType !== ListType.closed;
},
showMilestoneListDetails() {
return this.list.type === 'milestone' && this.list.milestone && this.showListDetails;
},
showAssigneeListDetails() {
return this.list.type === 'assignee' && this.showListDetails;
},
showIterationListDetails() {
return this.listType === ListType.iteration && this.showListDetails;
},
showListDetails() {
return this.list.isExpanded || !this.isSwimlanesHeader;
},
showListHeaderActions() {
if (this.isLoggedIn) {
return this.isNewIssueShown || this.isSettingsShown;
}
return false;
},
issuesCount() {
return this.list.issuesSize;
},
issuesTooltipLabel() {
return n__(`%d issue`, `%d issues`, this.issuesCount);
},
chevronTooltip() {
return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
},
chevronIcon() {
return this.list.isExpanded ? 'chevron-right' : 'chevron-down';
},
isNewIssueShown() {
return this.listType === ListType.backlog || this.showListHeaderButton;
},
isSettingsShown() {
return (
this.listType !== ListType.backlog && this.showListHeaderButton && this.list.isExpanded
);
},
uniqueKey() {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `boards.${this.boardId}.${this.listType}.${this.list.id}`;
},
collapsedTooltipTitle() {
return this.listTitle || this.listAssignee;
},
},
methods: {
...mapActions(['setActiveId']),
openSidebarSettings() {
if (this.activeId === inactiveId) {
sidebarEventHub.$emit('sidebar.closeAll');
}
this.setActiveId({ id: this.list.id, sidebarType: LIST });
},
showScopedLabels(label) {
return boardsStore.scopedLabels.enabled && isScopedLabel(label);
},
showNewIssueForm() {
eventHub.$emit(`toggle-issue-form-${this.list.id}`);
},
toggleExpanded() {
// eslint-disable-next-line vue/no-mutating-props
this.list.isExpanded = !this.list.isExpanded;
if (!this.isLoggedIn) {
this.addToLocalStorage();
} else {
this.updateListFunction();
}
// When expanding/collapsing, the tooltip on the caret button sometimes stays open.
// Close all tooltips manually to prevent dangling tooltips.
this.$root.$emit(BV_HIDE_TOOLTIP);
},
addToLocalStorage() {
if (AccessorUtilities.isLocalStorageAccessSafe()) {
localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded);
}
},
updateListFunction() {
this.list.update();
},
},
};
</script>
<template>
<header
:class="{
'has-border': list.label && list.label.color,
'gl-h-full': !list.isExpanded,
'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader,
}"
:style="{ borderTopColor: list.label && list.label.color ? list.label.color : null }"
class="board-header gl-relative"
data-qa-selector="board_list_header"
data-testid="board-list-header"
>
<h3
:class="{
'user-can-drag': !disabled && !list.preset,
'gl-py-3 gl-h-full': !list.isExpanded && !isSwimlanesHeader,
'gl-border-b-0': !list.isExpanded || isSwimlanesHeader,
'gl-py-2': !list.isExpanded && isSwimlanesHeader,
'gl-flex-direction-column': !list.isExpanded,
}"
class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 js-board-handle"
>
<gl-button
v-if="list.isExpandable"
v-gl-tooltip.hover
:aria-label="chevronTooltip"
:title="chevronTooltip"
:icon="chevronIcon"
class="board-title-caret no-drag gl-cursor-pointer"
category="tertiary"
size="small"
@click="toggleExpanded"
/>
<!-- The following is only true in EE and if it is a milestone -->
<span
v-if="showMilestoneListDetails"
aria-hidden="true"
class="milestone-icon"
:class="{
'gl-mt-3 gl-rotate-90': !list.isExpanded,
'gl-mr-2': list.isExpanded,
}"
>
<gl-icon name="timer" />
</span>
<span
v-if="showIterationListDetails"
aria-hidden="true"
:class="{
'gl-mt-3 gl-rotate-90': !list.isExpanded,
'gl-mr-2': list.isExpanded,
}"
>
<gl-icon name="iteration" />
</span>
<a
v-if="showAssigneeListDetails"
:href="list.assignee.path"
class="user-avatar-link js-no-trigger"
:class="{
'gl-mt-3 gl-rotate-90': !list.isExpanded,
}"
>
<img
v-gl-tooltip.hover.bottom
:title="listAssignee"
:alt="list.assignee.name"
:src="list.assignee.avatar"
class="avatar s20"
height="20"
width="20"
/>
</a>
<div
class="board-title-text"
:class="{
'gl-display-none': !list.isExpanded && isSwimlanesHeader,
'gl-flex-grow-0 gl-my-3 gl-mx-0': !list.isExpanded,
'gl-flex-grow-1': list.isExpanded,
}"
>
<span
v-if="list.type !== 'label'"
v-gl-tooltip.hover
:class="{
'gl-display-block': !list.isExpanded || list.type === 'milestone',
}"
:title="listTitle"
class="board-title-main-text gl-text-truncate"
>
{{ list.title }}
</span>
<span
v-if="list.type === 'assignee'"
class="gl-ml-2 gl-font-weight-normal gl-text-gray-500"
:class="{ 'gl-display-none': !list.isExpanded }"
>
@{{ listAssignee }}
</span>
<gl-label
v-if="list.type === 'label'"
v-gl-tooltip.hover.bottom
:background-color="list.label.color"
:description="list.label.description"
:scoped="showScopedLabels(list.label)"
:size="!list.isExpanded ? 'sm' : ''"
:title="list.label.title"
/>
</div>
<span
v-if="isSwimlanesHeader && !list.isExpanded"
ref="collapsedInfo"
aria-hidden="true"
class="board-header-collapsed-info-icon gl-mt-2 gl-cursor-pointer gl-text-gray-500"
>
<gl-icon name="information" />
</span>
<gl-tooltip v-if="isSwimlanesHeader && !list.isExpanded" :target="() => $refs.collapsedInfo">
<div class="gl-font-weight-bold gl-pb-2">{{ collapsedTooltipTitle }}</div>
<div v-if="list.maxIssueCount !== 0">
&#8226;
<gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')">
<template #issuesSize>{{ issuesTooltipLabel }}</template>
<template #maxIssueCount>{{ list.maxIssueCount }}</template>
</gl-sprintf>
</div>
<div v-else>&#8226; {{ issuesTooltipLabel }}</div>
<div v-if="weightFeatureAvailable">
&#8226;
<gl-sprintf :message="__('%{totalWeight} total weight')">
<template #totalWeight>{{ list.totalWeight }}</template>
</gl-sprintf>
</div>
</gl-tooltip>
<div
class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag text-secondary"
:class="{
'gl-display-none!': !list.isExpanded && isSwimlanesHeader,
'gl-p-0': !list.isExpanded,
}"
>
<span class="gl-display-inline-flex">
<gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" />
<span ref="issueCount" class="issue-count-badge-count">
<gl-icon class="gl-mr-2" name="issues" />
<issue-count :items-size="issuesCount" :max-issue-count="list.maxIssueCount" />
</span>
<!-- The following is only true in EE. -->
<template v-if="weightFeatureAvailable">
<gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" />
<span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3">
<gl-icon class="gl-mr-2" name="weight" />
{{ list.totalWeight }}
</span>
</template>
</span>
</div>
<gl-button-group v-if="showListHeaderActions" class="board-list-button-group pl-2">
<gl-button
v-if="isNewIssueShown"
ref="newIssueBtn"
v-gl-tooltip.hover
:class="{
'gl-display-none': !list.isExpanded,
}"
:aria-label="__('New issue')"
:title="__('New issue')"
class="issue-count-badge-add-button no-drag"
icon="plus"
@click="showNewIssueForm"
/>
<gl-button
v-if="isSettingsShown"
ref="settingsBtn"
v-gl-tooltip.hover
:aria-label="__('List settings')"
class="no-drag js-board-settings-button"
:title="__('List settings')"
icon="settings"
@click="openSidebarSettings"
/>
<gl-tooltip :target="() => $refs.settingsBtn">{{ __('List settings') }}</gl-tooltip>
</gl-button-group>
</h3>
</header>
</template>

View File

@ -1,138 +0,0 @@
<script>
import { GlButton } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import { getMilestone } from 'ee_else_ce/boards/boards_util';
import ListIssue from 'ee_else_ce/boards/models/issue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../eventhub';
import boardsStore from '../stores/boards_store';
import ProjectSelect from './project_select_deprecated.vue';
// This component is being replaced in favor of './board_new_issue.vue' for GraphQL boards
export default {
name: 'BoardNewIssueDeprecated',
components: {
ProjectSelect,
GlButton,
},
mixins: [glFeatureFlagMixin()],
inject: ['groupId'],
props: {
list: {
type: Object,
required: true,
},
},
data() {
return {
title: '',
error: false,
selectedProject: {},
};
},
computed: {
...mapGetters(['isGroupBoard']),
disabled() {
if (this.isGroupBoard) {
return this.title === '' || !this.selectedProject.name;
}
return this.title === '';
},
},
mounted() {
this.$refs.input.focus();
eventHub.$on('setSelectedProject', this.setSelectedProject);
},
methods: {
submit(e) {
e.preventDefault();
if (this.title.trim() === '') return Promise.resolve();
this.error = false;
const labels = this.list.label ? [this.list.label] : [];
const assignees = this.list.assignee ? [this.list.assignee] : [];
const milestone = getMilestone(this.list);
const { weightFeatureAvailable } = boardsStore;
const { weight } = weightFeatureAvailable ? boardsStore.state.currentBoard : {};
const issue = new ListIssue({
title: this.title,
labels,
subscribed: true,
assignees,
milestone,
project_id: this.selectedProject.id,
weight,
});
eventHub.$emit(`scroll-board-list-${this.list.id}`);
this.cancel();
return this.list
.newIssue(issue)
.then(() => {
boardsStore.setIssueDetail(issue);
boardsStore.setListDetail(this.list);
})
.catch(() => {
this.list.removeIssue(issue);
// Show error message
this.error = true;
});
},
cancel() {
this.title = '';
eventHub.$emit(`toggle-issue-form-${this.list.id}`);
},
setSelectedProject(selectedProject) {
this.selectedProject = selectedProject;
},
},
};
</script>
<template>
<div class="board-new-issue-form">
<div class="board-card position-relative p-3 rounded">
<form @submit="submit($event)">
<div v-if="error" class="flash-container">
<div class="flash-alert">{{ __('An error occurred. Please try again.') }}</div>
</div>
<label :for="list.id + '-title'" class="label-bold">{{ __('Title') }}</label>
<input
:id="list.id + '-title'"
ref="input"
v-model="title"
class="form-control"
type="text"
name="issue_title"
autocomplete="off"
/>
<project-select v-if="isGroupBoard" :group-id="groupId" :list="list" />
<div class="clearfix gl-mt-3">
<gl-button
ref="submitButton"
:disabled="disabled"
class="float-left js-no-auto-disable"
variant="success"
category="primary"
type="submit"
>{{ __('Create issue') }}</gl-button
>
<gl-button
ref="cancelButton"
class="float-right"
type="button"
variant="default"
@click="cancel"
>{{ __('Cancel') }}</gl-button
>
</div>
</form>
</div>
</div>
</template>

View File

@ -1,247 +0,0 @@
<script>
import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { sortBy } from 'lodash';
import { mapState } from 'vuex';
import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { sprintf, __, n__ } from '~/locale';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import boardsStore from '../stores/boards_store';
import IssueDueDate from './issue_due_date.vue';
import IssueTimeEstimate from './issue_time_estimate_deprecated.vue';
export default {
components: {
GlLabel,
GlIcon,
UserAvatarLink,
TooltipOnTruncate,
IssueDueDate,
IssueTimeEstimate,
IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [boardCardInner],
inject: ['groupId', 'rootPath'],
props: {
issue: {
type: Object,
required: true,
},
list: {
type: Object,
required: false,
default: () => ({}),
},
updateFilters: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
limitBeforeCounter: 2,
maxRender: 3,
maxCounter: 99,
};
},
computed: {
...mapState(['isShowingLabels']),
numberOverLimit() {
return this.issue.assignees.length - this.limitBeforeCounter;
},
assigneeCounterTooltip() {
const { numberOverLimit, maxCounter } = this;
const count = numberOverLimit > maxCounter ? maxCounter : numberOverLimit;
return sprintf(__('%{count} more assignees'), { count });
},
assigneeCounterLabel() {
if (this.numberOverLimit > this.maxCounter) {
return `${this.maxCounter}+`;
}
return `+${this.numberOverLimit}`;
},
shouldRenderCounter() {
if (this.issue.assignees.length <= this.maxRender) {
return false;
}
return this.issue.assignees.length > this.numberOverLimit;
},
issueId() {
if (this.issue.iid) {
return `#${this.issue.iid}`;
}
return false;
},
showLabelFooter() {
return this.isShowingLabels && this.issue.labels.find(this.showLabel);
},
issueReferencePath() {
const { referencePath, groupId } = this.issue;
return !groupId ? referencePath.split('#')[0] : null;
},
orderedLabels() {
return sortBy(this.issue.labels.filter(this.isNonListLabel), 'title');
},
blockedLabel() {
if (this.issue.blockedByCount) {
return n__(`Blocked by %d issue`, `Blocked by %d issues`, this.issue.blockedByCount);
}
return __('Blocked issue');
},
assignees() {
return this.issue.assignees.filter((_, index) => this.shouldRenderAssignee(index));
},
},
methods: {
isIndexLessThanlimit(index) {
return index < this.limitBeforeCounter;
},
shouldRenderAssignee(index) {
// Eg. maxRender is 4,
// Render up to all 4 assignees if there are only 4 assigness
// Otherwise render up to the limitBeforeCounter
if (this.issue.assignees.length <= this.maxRender) {
return index < this.maxRender;
}
return index < this.limitBeforeCounter;
},
assigneeUrl(assignee) {
if (!assignee) return '';
return `${this.rootPath}${assignee.username}`;
},
avatarUrlTitle(assignee) {
return sprintf(__(`Avatar for %{assigneeName}`), { assigneeName: assignee.name });
},
showLabel(label) {
if (!label.id) return false;
return true;
},
isNonListLabel(label) {
return label.id && !(this.list.type === 'label' && this.list.title === label.title);
},
filterByLabel(label) {
if (!this.updateFilters) return;
const labelTitle = encodeURIComponent(label.title);
const filter = `label_name[]=${labelTitle}`;
boardsStore.toggleFilter(filter);
},
showScopedLabel(label) {
return boardsStore.scopedLabels.enabled && isScopedLabel(label);
},
},
};
</script>
<template>
<div>
<div class="gl-display-flex" dir="auto">
<h4 class="board-card-title gl-mb-0 gl-mt-0">
<gl-icon
v-if="issue.blocked"
v-gl-tooltip
name="issue-block"
:title="blockedLabel"
class="issue-blocked-icon gl-mr-2"
:aria-label="blockedLabel"
data-testid="issue-blocked-icon"
/>
<gl-icon
v-if="issue.confidential"
v-gl-tooltip
name="eye-slash"
:title="__('Confidential')"
class="confidential-icon gl-mr-2"
:aria-label="__('Confidential')"
/>
<a
:href="issue.path || issue.webUrl || ''"
:title="issue.title"
class="js-no-trigger"
@mousemove.stop
>{{ issue.title }}</a
>
</h4>
</div>
<div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap">
<template v-for="label in orderedLabels">
<gl-label
:key="label.id"
:background-color="label.color"
:title="label.title"
:description="label.description"
size="sm"
:scoped="showScopedLabel(label)"
@click="filterByLabel(label)"
/>
</template>
</div>
<div
class="board-card-footer gl-display-flex gl-justify-content-space-between gl-align-items-flex-end"
>
<div
class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden js-board-card-number-container"
>
<span
v-if="issue.referencePath"
class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3"
>
<tooltip-on-truncate
v-if="issueReferencePath"
:title="issueReferencePath"
placement="bottom"
class="board-issue-path gl-text-truncate gl-font-weight-bold"
>{{ issueReferencePath }}</tooltip-on-truncate
>
#{{ issue.iid }}
</span>
<span class="board-info-items gl-mt-3 gl-display-inline-block">
<issue-due-date
v-if="issue.dueDate"
:date="issue.dueDate"
:closed="issue.closed || Boolean(issue.closedAt)"
/>
<issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" />
<issue-card-weight
v-if="validIssueWeight(issue)"
:weight="issue.weight"
@click="filterByWeight(issue.weight)"
/>
</span>
</div>
<div class="board-card-assignee gl-display-flex">
<user-avatar-link
v-for="assignee in assignees"
:key="assignee.id"
:link-href="assigneeUrl(assignee)"
:img-alt="avatarUrlTitle(assignee)"
:img-src="assignee.avatarUrl || assignee.avatar || assignee.avatar_url"
:img-size="24"
class="js-no-trigger"
tooltip-placement="bottom"
>
<span class="js-assignee-tooltip">
<span class="gl-font-weight-bold gl-display-block">{{ __('Assignee') }}</span>
{{ assignee.name }}
<span class="text-white-50">@{{ assignee.username }}</span>
</span>
</user-avatar-link>
<span
v-if="shouldRenderCounter"
v-gl-tooltip
:title="assigneeCounterTooltip"
class="avatar-counter"
data-placement="bottom"
>{{ assigneeCounterLabel }}</span
>
</div>
</div>
</div>
</template>

View File

@ -1,48 +0,0 @@
<script>
import { GlTooltip, GlIcon } from '@gitlab/ui';
import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility';
import boardsStore from '../stores/boards_store';
export default {
components: {
GlIcon,
GlTooltip,
},
props: {
estimate: {
type: [Number, String],
required: true,
},
},
data() {
return {
limitToHours: boardsStore.timeTracking.limitToHours,
};
},
computed: {
title() {
return stringifyTime(parseSeconds(this.estimate, { limitToHours: this.limitToHours }), true);
},
timeEstimate() {
return stringifyTime(parseSeconds(this.estimate, { limitToHours: this.limitToHours }));
},
},
};
</script>
<template>
<span>
<span ref="issueTimeEstimate" class="board-card-info card-number">
<gl-icon name="hourglass" class="board-card-info-icon" /><time class="board-card-info-text">{{
timeEstimate
}}</time>
</span>
<gl-tooltip
:target="() => $refs.issueTimeEstimate"
placement="bottom"
class="js-issue-time-estimate"
>
<span class="bold d-block">{{ __('Time estimate') }}</span> {{ title }}
</gl-tooltip>
</span>
</template>

View File

@ -1,146 +0,0 @@
<script>
import {
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlSearchBoxByType,
GlLoadingIcon,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
import Api from '../../api';
import { ListType } from '../constants';
import eventHub from '../eventhub';
export default {
name: 'ProjectSelect',
i18n: {
headerTitle: s__(`BoardNewIssue|Projects`),
dropdownText: s__(`BoardNewIssue|Select a project`),
searchPlaceholder: s__(`BoardNewIssue|Search projects`),
emptySearchResult: s__(`BoardNewIssue|No matching results`),
},
defaultFetchOptions: {
with_issues_enabled: true,
with_shared: false,
include_subgroups: true,
order_by: 'similarity',
archived: false,
},
components: {
GlLoadingIcon,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlSearchBoxByType,
},
inject: ['groupId'],
props: {
list: {
type: Object,
required: true,
},
},
data() {
return {
initialLoading: true,
isFetching: false,
projects: [],
selectedProject: {},
searchTerm: '',
};
},
computed: {
selectedProjectName() {
return this.selectedProject.name || this.$options.i18n.dropdownText;
},
fetchOptions() {
const additionalAttrs = {};
if (this.list.type && this.list.type !== ListType.backlog) {
additionalAttrs.min_access_level = featureAccessLevel.EVERYONE;
}
return {
...this.$options.defaultFetchOptions,
...additionalAttrs,
};
},
isFetchResultEmpty() {
return this.projects.length === 0;
},
},
watch: {
searchTerm() {
this.fetchProjects();
},
},
async mounted() {
await this.fetchProjects();
this.initialLoading = false;
},
methods: {
async fetchProjects() {
this.isFetching = true;
try {
const projects = await Api.groupProjects(this.groupId, this.searchTerm, this.fetchOptions);
this.projects = projects.map((project) => {
return {
id: project.id,
name: project.name,
namespacedName: project.name_with_namespace,
path: project.path_with_namespace,
};
});
} catch (err) {
/* Handled in Api.groupProjects */
} finally {
this.isFetching = false;
}
},
selectProject(projectId) {
this.selectedProject = this.projects.find((project) => project.id === projectId);
eventHub.$emit('setSelectedProject', this.selectedProject);
},
},
};
</script>
<template>
<div>
<label class="gl-font-weight-bold gl-mt-3" data-testid="header-label">{{
$options.i18n.headerTitle
}}</label>
<gl-dropdown
data-testid="project-select-dropdown"
:text="selectedProjectName"
:header-text="$options.i18n.headerTitle"
block
menu-class="gl-w-full!"
:loading="initialLoading"
>
<gl-search-box-by-type
v-model.trim="searchTerm"
debounce="250"
:placeholder="$options.i18n.searchPlaceholder"
/>
<gl-dropdown-item
v-for="project in projects"
v-show="!isFetching"
:key="project.id"
:name="project.name"
@click="selectProject(project.id)"
>
{{ project.namespacedName }}
</gl-dropdown-item>
<gl-dropdown-text v-show="isFetching" data-testid="dropdown-text-loading-icon">
<gl-loading-icon class="gl-mx-auto" size="sm" />
</gl-dropdown-text>
<gl-dropdown-text v-if="isFetchResultEmpty && !isFetching" data-testid="empty-result-message">
<span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span>
</gl-dropdown-text>
</gl-dropdown>
</div>
</template>

View File

@ -31,9 +31,18 @@ module Gitlab
require_dependency Rails.root.join('lib/gitlab/middleware/handle_malformed_strings')
require_dependency Rails.root.join('lib/gitlab/middleware/rack_multipart_tempfile_factory')
require_dependency Rails.root.join('lib/gitlab/runtime')
require_dependency Rails.root.join('lib/gitlab/patch/legacy_database_config')
config.autoloader = :zeitwerk
# To be removed in 15.0
# This preload is needed to convert legacy `database.yml`
# from `production: adapter: postgresql`
# into a `production: main: adapter: postgresql`
unless Gitlab::Utils.to_boolean(ENV['SKIP_DATABASE_CONFIG_VALIDATION'], default: false)
config.class.prepend(::Gitlab::Patch::LegacyDatabaseConfig)
end
# Settings in config/environments/* take precedence over those specified here.
# Application configuration should go into files in config/initializers
# -- all .rb files in that directory are automatically loaded.

View File

@ -2,56 +2,60 @@
# PRODUCTION
#
production:
adapter: postgresql
encoding: unicode
database: gitlabhq_production
username: git
password: "secure password"
host: localhost
# load_balancing:
# hosts:
# - host1.example.com
# - host2.example.com
# discover:
# nameserver: 1.2.3.4
# port: 8600
# record: secondary.postgresql.service.consul
# interval: 300
main:
adapter: postgresql
encoding: unicode
database: gitlabhq_production
username: git
password: "secure password"
host: localhost
# load_balancing:
# hosts:
# - host1.example.com
# - host2.example.com
# discover:
# nameserver: 1.2.3.4
# port: 8600
# record: secondary.postgresql.service.consul
# interval: 300
#
# Development specific
#
development:
adapter: postgresql
encoding: unicode
database: gitlabhq_development
username: postgres
password: "secure password"
host: localhost
variables:
statement_timeout: 15s
main:
adapter: postgresql
encoding: unicode
database: gitlabhq_development
username: postgres
password: "secure password"
host: localhost
variables:
statement_timeout: 15s
#
# Staging specific
#
staging:
adapter: postgresql
encoding: unicode
database: gitlabhq_staging
username: git
password: "secure password"
host: localhost
main:
adapter: postgresql
encoding: unicode
database: gitlabhq_staging
username: git
password: "secure password"
host: localhost
# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test: &test
adapter: postgresql
encoding: unicode
database: gitlabhq_test
username: postgres
password:
host: localhost
prepared_statements: false
variables:
statement_timeout: 15s
main:
adapter: postgresql
encoding: unicode
database: gitlabhq_test
username: postgres
password:
host: localhost
prepared_statements: false
variables:
statement_timeout: 15s

View File

@ -1,8 +0,0 @@
---
name: gitaly_backup
introduced_by_url: https://gitlab.com/gitlab-org/gitaly/-/merge_requests/3554
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/333034
milestone: '14.0'
type: development
group: group::gitaly
default_enabled: true

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
if Gitlab::Utils.to_boolean(ENV['SKIP_DATABASE_CONFIG_VALIDATION'], default: false)
return
end
if Rails.application.config.uses_legacy_database_config
warn "WARNING: This installation of GitLab uses a deprecated syntax for 'config/database.yml'. " \
"The support for this syntax will be removed in 15.0. " \
"More information can be found here: https://gitlab.com/gitlab-org/gitlab/-/issues/338182"
end
if configurations = ActiveRecord::Base.configurations.configurations
if configurations.first.name != Gitlab::Database::MAIN_DATABASE_NAME
raise "ERROR: This installation of GitLab uses unsupported 'config/database.yml'. " \
"The `main:` database needs to be defined as a first configuration item instead of `#{configurations.first.name}`."
end
rejected_config_names = configurations.map(&:name).to_set - Gitlab::Database::DATABASE_NAMES
if rejected_config_names.any?
raise "ERROR: This installation of GitLab uses unsupported database names " \
"in 'config/database.yml': #{rejected_config_names.to_a.join(", ")}. The only supported ones are " \
"#{Gitlab::Database::DATABASE_NAMES.join(", ")}."
end
replicas_config_names = configurations.select(&:replica?).map(&:name)
if replicas_config_names.any?
raise "ERROR: This installation of GitLab uses unsupported database configuration " \
"with 'replica: true' parameter in 'config/database.yml' for: #{replicas_config_names.join(", ")}"
end
end

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
class RemoveDuplicateDastSiteTokens < ActiveRecord::Migration[6.1]
disable_ddl_transaction!
class DastSiteToken < ApplicationRecord
self.table_name = 'dast_site_tokens'
self.inheritance_column = :_type_disabled
scope :duplicates, -> do
all_duplicates = select(:project_id, :url)
.distinct
.group(:project_id, :url)
.having('count(*) > 1')
.pluck('array_agg(id) as ids')
duplicate_ids = extract_duplicate_ids(all_duplicates)
where(id: duplicate_ids)
end
def self.extract_duplicate_ids(duplicates)
duplicates.flat_map { |ids| ids.first(ids.size - 1) }
end
end
def up
DastSiteToken.duplicates.delete_all
end
def down
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
class AddUniqueIndexDastSiteTokenProjectIdAndUrl < ActiveRecord::Migration[6.1]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
INDEX_NAME = 'index_dast_site_token_on_project_id_and_url'
def up
add_concurrent_index :dast_site_tokens, [:project_id, :url], name: INDEX_NAME, unique: true
end
def down
remove_concurrent_index_by_name :dast_site_tokens, name: INDEX_NAME
end
end

View File

@ -0,0 +1,80 @@
# frozen_string_literal: true
class PrepareAsyncIndexesForCiBuilds < Gitlab::Database::Migration[1.0]
def up
prepare_async_index :ci_builds, :stage_id_convert_to_bigint, name: :index_ci_builds_on_converted_stage_id
prepare_async_index :ci_builds, [:commit_id, :artifacts_expire_at, :id_convert_to_bigint],
where: "type::text = 'Ci::Build'::text
AND (retried = false OR retried IS NULL)
AND (name::text = ANY (ARRAY['sast'::character varying::text,
'secret_detection'::character varying::text,
'dependency_scanning'::character varying::text,
'container_scanning'::character varying::text,
'dast'::character varying::text]))",
name: :index_ci_builds_on_commit_id_expire_at_and_converted_id
prepare_async_index :ci_builds, [:project_id, :id_convert_to_bigint],
name: :index_ci_builds_on_project_and_converted_id
prepare_async_index :ci_builds, [:runner_id, :id_convert_to_bigint],
order: { id_convert_to_bigint: :desc },
name: :index_ci_builds_on_runner_id_and_converted_id_desc
prepare_async_index :ci_builds, [:resource_group_id, :id_convert_to_bigint],
where: 'resource_group_id IS NOT NULL',
name: :index_ci_builds_on_resource_group_and_converted_id
prepare_async_index :ci_builds, [:name, :id_convert_to_bigint],
where: "(name::text = ANY (ARRAY['container_scanning'::character varying::text,
'dast'::character varying::text,
'dependency_scanning'::character varying::text,
'license_management'::character varying::text,
'sast'::character varying::text,
'secret_detection'::character varying::text,
'coverage_fuzzing'::character varying::text,
'license_scanning'::character varying::text])
) AND type::text = 'Ci::Build'::text",
name: :index_security_ci_builds_on_name_and_converted_id_parser
prepare_async_index_from_sql(:ci_builds, :index_ci_builds_runner_id_and_converted_id_pending_covering, <<~SQL.squish)
CREATE INDEX CONCURRENTLY index_ci_builds_runner_id_and_converted_id_pending_covering
ON ci_builds (runner_id, id_convert_to_bigint) INCLUDE (project_id)
WHERE status::text = 'pending'::text AND type::text = 'Ci::Build'::text
SQL
end
def down
unprepare_async_index_by_name :ci_builds, :index_ci_builds_runner_id_and_converted_id_pending_covering
unprepare_async_index_by_name :ci_builds, :index_security_ci_builds_on_name_and_converted_id_parser
unprepare_async_index_by_name :ci_builds, :index_ci_builds_on_resource_group_and_converted_id
unprepare_async_index_by_name :ci_builds, :index_ci_builds_on_runner_id_and_converted_id_desc
unprepare_async_index_by_name :ci_builds, :index_ci_builds_on_project_and_converted_id
unprepare_async_index_by_name :ci_builds, :index_ci_builds_on_commit_id_expire_at_and_converted_id
unprepare_async_index_by_name :ci_builds, :index_ci_builds_on_converted_stage_id
end
private
def prepare_async_index_from_sql(table_name, index_name, definition)
return unless async_index_creation_available?
return if index_name_exists?(table_name, index_name)
async_index = Gitlab::Database::AsyncIndexes::PostgresAsyncIndex.find_or_create_by!(name: index_name) do |rec|
rec.table_name = table_name
rec.definition = definition
end
Gitlab::AppLogger.info(
message: 'Prepared index for async creation',
table_name: async_index.table_name,
index_name: async_index.name)
end
end

View File

@ -0,0 +1 @@
7324c3803c910338261556c65cae5d0827e78b77890386e402e056d480c3486b

View File

@ -0,0 +1 @@
b7916e025131f11da97ab89a01b32d1dbacf94bb96dc84877ba18404c8b0b2ba

View File

@ -0,0 +1 @@
40780a28f881d4e80bdb6b023f22804c4da735223323c8cf03cfcdcaf5337fe6

View File

@ -9606,14 +9606,14 @@ CREATE TABLE application_settings (
encrypted_customers_dot_jwt_signing_key bytea,
encrypted_customers_dot_jwt_signing_key_iv bytea,
pypi_package_requests_forwarding boolean DEFAULT true NOT NULL,
max_yaml_size_bytes bigint DEFAULT 1048576 NOT NULL,
max_yaml_depth integer DEFAULT 100 NOT NULL,
throttle_unauthenticated_files_api_requests_per_period integer DEFAULT 125 NOT NULL,
throttle_unauthenticated_files_api_period_in_seconds integer DEFAULT 15 NOT NULL,
throttle_authenticated_files_api_requests_per_period integer DEFAULT 500 NOT NULL,
throttle_authenticated_files_api_period_in_seconds integer DEFAULT 15 NOT NULL,
throttle_unauthenticated_files_api_enabled boolean DEFAULT false NOT NULL,
throttle_authenticated_files_api_enabled boolean DEFAULT false NOT NULL,
max_yaml_size_bytes bigint DEFAULT 1048576 NOT NULL,
max_yaml_depth integer DEFAULT 100 NOT NULL,
throttle_authenticated_git_lfs_requests_per_period integer DEFAULT 1000 NOT NULL,
throttle_authenticated_git_lfs_period_in_seconds integer DEFAULT 60 NOT NULL,
throttle_authenticated_git_lfs_enabled boolean DEFAULT false NOT NULL,
@ -23846,6 +23846,8 @@ CREATE UNIQUE INDEX index_dast_site_profiles_on_project_id_and_name ON dast_site
CREATE UNIQUE INDEX index_dast_site_profiles_pipelines_on_ci_pipeline_id ON dast_site_profiles_pipelines USING btree (ci_pipeline_id);
CREATE UNIQUE INDEX index_dast_site_token_on_project_id_and_url ON dast_site_tokens USING btree (project_id, url);
CREATE INDEX index_dast_site_tokens_on_project_id ON dast_site_tokens USING btree (project_id);
CREATE INDEX index_dast_site_validations_on_dast_site_token_id ON dast_site_validations USING btree (dast_site_token_id);

View File

@ -1477,16 +1477,8 @@ If this happens, examine the following:
### `gitaly-backup` for repository backup and restore **(FREE SELF)**
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/333034) in GitLab 14.2.
> - [Deployed behind a feature flag](../user/feature_flags.md), enabled by default.
> - Recommended for production use.
> - For GitLab self-managed instances, GitLab administrators can opt to [disable it](#disable-or-enable-gitaly-backup).
There can be
[risks when disabling released features](../administration/feature_flags.md#risks-when-disabling-released-features).
Refer to this feature's version history for more details.
`gitaly-backup` is used by the backup Rake task to create and restore repository backups from Gitaly.
`gitaly-backup` replaces the previous backup method that directly calls RPCs on Gitaly from GitLab.
The backup Rake task must be able to find this executable. It can be configured in Omnibus GitLab packages:
@ -1498,22 +1490,3 @@ The backup Rake task must be able to find this executable. It can be configured
1. [Reconfigure GitLab](../administration/restart_gitlab.md#omnibus-gitlab-reconfigure)
for the changes to take effect
#### Disable or enable `gitaly-backup`
`gitaly-backup` is under development but ready for production use.
It is deployed behind a feature flag that is **enabled by default**.
[GitLab administrators with access to the GitLab Rails console](../administration/feature_flags.md)
can opt to disable it.
To disable it:
```ruby
Feature.disable(:gitaly_backup)
```
To enable it:
```ruby
Feature.enable(:gitaly_backup)
```

View File

@ -120,6 +120,13 @@ SSO has the following effects when enabled:
- Users must be signed-in through SSO before they can pull images using the [Dependency Proxy](../../packages/dependency_proxy/index.md).
<!-- Add bullet for API activity when https://gitlab.com/gitlab-org/gitlab/-/issues/9152 is complete -->
Notes:
- When SSO is enforced users are not immediately revoked
- If they are signed out then they cannot access the group after being removed from the identity provider
- However, if the user has an active session they can continue accessing the group for up to 24 hours, until the identity provider session times out
- Upon SCIM update, the user's access would be immediately revoked
## Providers
The SAML standard means that a wide range of identity providers will work with GitLab. Your identity provider may have relevant documentation. It may be generic SAML documentation, or specifically targeted for GitLab.
@ -140,13 +147,13 @@ Follow the Azure documentation on [configuring single sign-on to applications](h
For a demo of the Azure SAML setup including SCIM, see [SCIM Provisioning on Azure Using SAML SSO for Groups Demo](https://youtu.be/24-ZxmTeEBU). The video is outdated in regard to
objectID mapping and the [SCIM documentation should be followed](scim_setup.md#azure-configuration-steps).
| GitLab Setting | Azure Field |
|--------------|----------------|
| Identifier | Identifier (Entity ID) |
| Assertion consumer service URL | Reply URL (Assertion Consumer Service URL) |
| GitLab single sign-on URL | Sign on URL |
| Identity provider single sign-on URL | Login URL |
| Certificate fingerprint | Thumbprint |
| GitLab Setting | Azure Field |
| ------------------------------------ | ------------------------------------------ |
| Identifier | Identifier (Entity ID) |
| Assertion consumer service URL | Reply URL (Assertion Consumer Service URL) |
| GitLab single sign-on URL | Sign on URL |
| Identity provider single sign-on URL | Login URL |
| Certificate fingerprint | Thumbprint |
We recommend:
@ -164,12 +171,12 @@ Please follow the Okta documentation on [setting up a SAML application in Okta](
<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
For a demo of the Okta SAML setup including SCIM, see [Demo: Okta Group SAML & SCIM setup](https://youtu.be/0ES9HsZq0AQ).
| GitLab Setting | Okta Field |
|--------------|----------------|
| Identifier | Audience URI |
| Assertion consumer service URL | Single sign-on URL |
| GitLab single sign-on URL | Login page URL (under **Application Login Page** settings) |
| Identity provider single sign-on URL | Identity Provider Single Sign-On URL |
| GitLab Setting | Okta Field |
| ------------------------------------ | ---------------------------------------------------------- |
| Identifier | Audience URI |
| Assertion consumer service URL | Single sign-on URL |
| GitLab single sign-on URL | Login page URL (under **Application Login Page** settings) |
| Identity provider single sign-on URL | Identity Provider Single Sign-On URL |
Under Okta's **Single sign-on URL** field, check the option **Use this for Recipient URL and Destination URL**.
@ -186,14 +193,14 @@ application.
If you decide to use the OneLogin generic [SAML Test Connector (Advanced)](https://onelogin.service-now.com/support?id=kb_article&sys_id=b2c19353dbde7b8024c780c74b9619fb&kb_category=93e869b0db185340d5505eea4b961934),
we recommend the ["Use the OneLogin SAML Test Connector" documentation](https://onelogin.service-now.com/support?id=kb_article&sys_id=93f95543db109700d5505eea4b96198f) with the following settings:
| GitLab Setting | OneLogin Field |
|--------------|----------------|
| Identifier | Audience |
| Assertion consumer service URL | Recipient |
| Assertion consumer service URL | ACS (Consumer) URL |
| GitLab Setting | OneLogin Field |
| ------------------------------------------------ | ---------------------------- |
| Identifier | Audience |
| Assertion consumer service URL | Recipient |
| Assertion consumer service URL | ACS (Consumer) URL |
| Assertion consumer service URL (escaped version) | ACS (Consumer) URL Validator |
| GitLab single sign-on URL | Login URL |
| Identity provider single sign-on URL | SAML 2.0 Endpoint |
| GitLab single sign-on URL | Login URL |
| Identity provider single sign-on URL | SAML 2.0 Endpoint |
Recommended `NameID` value: `OneLogin ID`.
@ -281,10 +288,7 @@ If a user is already a member of the group, linking the SAML identity does not c
### Blocking access
To rescind access to the group, perform the following steps, in order:
1. Remove the user from the user data store on the identity provider or the list of users on the specific app.
1. Remove the user from the GitLab.com group.
Please refer to [Blocking access via SCiM](scim_setup.md#blocking-access).
### Unlinking accounts
@ -406,14 +410,14 @@ If you do not wish to use that GitLab user with the SAML login, you can [unlink
The user that you're signed in with already has SAML linked to a different identity.
Here are possible causes and solutions:
| Cause | Solution |
|------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Cause | Solution |
| ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| You've tried to link multiple SAML identities to the same user, for a given identity provider. | Change the identity that you sign in with. To do so, [unlink the previous SAML identity](#unlinking-accounts) from this GitLab account before attempting to sign in again. |
### Message: "SAML authentication failed: Email has already been taken"
| Cause | Solution |
|------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------|
| ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| When a user account with the email address already exists in GitLab, but the user does not have the SAML identity tied to their account. | The user will need to [link their account](#user-access-and-management). |
### Message: "SAML authentication failed: Extern UID has already been taken, User has already been taken"
@ -439,8 +443,8 @@ Alternatively, when users need to [link SAML to their existing GitLab.com accoun
### The NameID has changed
| Cause | Solution |
|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Cause | Solution |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| As mentioned in the [NameID](#nameid) section, if the NameID changes for any user, the user can be locked out. This is a common problem when an email address is used as the identifier. | Follow the steps outlined in the ["SAML authentication failed: User has already been taken"](#message-saml-authentication-failed-user-has-already-been-taken) section. |
### I need to change my SAML app

View File

@ -59,6 +59,7 @@ During this configuration, note the following:
[previous step](#gitlab-configuration).
- It is recommended to set a notification email and check the **Send an email notification when a failure occurs** checkbox.
- For mappings, we will only leave `Synchronize Azure Active Directory Users to AppName` enabled.
- `Synchronize Azure Active Directory Groups to AppName` should be disabled. However, this does not mean Azure AD users cannot be provisioned in groups. Leaving it enabled does not break the SCIM user provisioning, but causes errors in Azure AD that may be confusing and misleading.
You can then test the connection by clicking on **Test Connection**. If the connection is successful, be sure to save your configuration before moving on. See below for [troubleshooting](#troubleshooting).
@ -71,11 +72,11 @@ your SAML configuration differs from [the recommended SAML settings](index.md#az
modify the corresponding `customappsso` settings accordingly. If a mapping is not listed in the
table, use the Azure defaults.
| Azure Active Directory Attribute | `customappsso` Attribute | Matching precedence |
| -------------------------------- | ---------------------- | -------------------- |
| `objectId` | `externalId` | 1 |
| `userPrincipalName` | `emails[type eq "work"].value` | |
| `mailNickname` | `userName` | |
| Azure Active Directory Attribute | `customappsso` Attribute | Matching precedence |
| -------------------------------- | ------------------------------ | ------------------- |
| `objectId` | `externalId` | 1 |
| `userPrincipalName` | `emails[type eq "work"].value` | |
| `mailNickname` | `userName` | |
For guidance, you can view [an example configuration in the troubleshooting reference](../../../administration/troubleshooting/group_saml_scim.md#azure-active-directory).
@ -162,6 +163,11 @@ graph TD
B -->|Yes| D[GitLab sends message back 'Email exists']
```
During provisioning, note the following:
- Both primary and secondary emails are considered when checking whether a GitLab user account exists.
- Duplicate usernames are also handled, by adding suffix `1` upon user creation. E.g. due to already existing `test_user` username, `test_user1` is used).
As long as [Group SAML](index.md) has been configured, existing GitLab.com users can link to their accounts in one of the following ways:
- By updating their *primary* email address in their GitLab.com user account to match their identity provider's user profile email address.
@ -183,13 +189,12 @@ For role information, please see the [Group SAML page](index.md#user-access-and-
### Blocking access
To rescind access to the group, remove the user from the identity
provider or users list for the specific app.
Upon the next sync, the user is deprovisioned, which means that the user is removed from the group.
To rescind access to the top-level group and all sub-groups and projects remove or deactivate the user on the identity provider.
SCIM providers will generally update GitLab with the changes on-demand, which is minutes at most.
The user's membership is revoked and they immediately lose access.
NOTE:
Deprovisioning does not delete the user account.
Deprovisioning does not delete the GitLab user account.
```mermaid
graph TD
@ -260,9 +265,9 @@ Alternatively, users can be removed from the SCIM app which de-links all removed
Changing the SAML or SCIM configuration or provider can cause the following problems:
| Problem | Solution |
|------------------------------------------------------------------------------|--------------------|
| SAML and SCIM identity mismatch. | First [verify that the user's SAML NameId matches the SCIM externalId](#how-do-i-verify-users-saml-nameid-matches-the-scim-externalid) and then [update or fix the mismatched SCIM externalId and SAML NameId](#update-or-fix-mismatched-scim-externalid-and-saml-nameid). |
| Problem | Solution |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| SAML and SCIM identity mismatch. | First [verify that the user's SAML NameId matches the SCIM externalId](#how-do-i-verify-users-saml-nameid-matches-the-scim-externalid) and then [update or fix the mismatched SCIM externalId and SAML NameId](#update-or-fix-mismatched-scim-externalid-and-saml-nameid). |
| SCIM identity mismatch between GitLab and the Identify Provider SCIM app. | You can confirm whether you're hitting the error because of your SCIM identity mismatch between your SCIM app and GitLab.com by using [SCIM API](../../../api/scim.md#update-a-single-scim-provisioned-user) which shows up in the `id` key and compares it with the user `externalId` in the SCIM app. You can use the same [SCIM API](../../../api/scim.md#update-a-single-scim-provisioned-user) to update the SCIM `id` for the user on GitLab.com. |
### Azure

View File

@ -57,10 +57,6 @@ module Backup
}.merge(Gitlab::GitalyClient.connection_data(repository.storage)).to_json)
end
def parallel_enqueue?
false
end
private
def started?

View File

@ -1,132 +0,0 @@
# frozen_string_literal: true
module Backup
# Backup and restores repositories using the gitaly RPC
class GitalyRpcBackup
def initialize(progress)
@progress = progress
end
def start(type)
raise Error, 'already started' if @type
@type = type
case type
when :create
FileUtils.rm_rf(backup_repos_path)
FileUtils.mkdir_p(Gitlab.config.backup.path)
FileUtils.mkdir(backup_repos_path, mode: 0700)
when :restore
# no op
else
raise Error, "unknown backup type: #{type}"
end
end
def wait
@type = nil
end
def enqueue(container, repository_type)
backup_restore = BackupRestore.new(
progress,
repository_type.repository_for(container),
backup_repos_path
)
case @type
when :create
backup_restore.backup
when :restore
backup_restore.restore(always_create: repository_type.project?)
else
raise Error, 'not started'
end
end
def parallel_enqueue?
true
end
private
attr_reader :progress
def backup_repos_path
@backup_repos_path ||= File.join(Gitlab.config.backup.path, 'repositories')
end
class BackupRestore
attr_accessor :progress, :repository, :backup_repos_path
def initialize(progress, repository, backup_repos_path)
@progress = progress
@repository = repository
@backup_repos_path = backup_repos_path
end
def backup
progress.puts " * #{display_repo_path} ... "
if repository.empty?
progress.puts " * #{display_repo_path} ... " + "[EMPTY] [SKIPPED]".color(:cyan)
return
end
FileUtils.mkdir_p(repository_backup_path)
repository.bundle_to_disk(path_to_bundle)
repository.gitaly_repository_client.backup_custom_hooks(custom_hooks_tar)
progress.puts " * #{display_repo_path} ... " + "[DONE]".color(:green)
rescue StandardError => e
progress.puts "[Failed] backing up #{display_repo_path}".color(:red)
progress.puts "Error #{e}".color(:red)
end
def restore(always_create: false)
progress.puts " * #{display_repo_path} ... "
repository.remove rescue nil
if File.exist?(path_to_bundle)
repository.create_from_bundle(path_to_bundle)
restore_custom_hooks
elsif always_create
repository.create_repository
end
progress.puts " * #{display_repo_path} ... " + "[DONE]".color(:green)
rescue StandardError => e
progress.puts "[Failed] restoring #{display_repo_path}".color(:red)
progress.puts "Error #{e}".color(:red)
end
private
def display_repo_path
"#{repository.full_path} (#{repository.disk_path})"
end
def repository_backup_path
@repository_backup_path ||= File.join(backup_repos_path, repository.disk_path)
end
def path_to_bundle
@path_to_bundle ||= File.join(backup_repos_path, repository.disk_path + '.bundle')
end
def restore_custom_hooks
return unless File.exist?(custom_hooks_tar)
repository.gitaly_repository_client.restore_custom_hooks(custom_hooks_tar)
end
def custom_hooks_tar
File.join(repository_backup_path, "custom_hooks.tar")
end
end
end
end

View File

@ -9,36 +9,10 @@ module Backup
@strategy = strategy
end
def dump(max_concurrency:, max_storage_concurrency:)
def dump
strategy.start(:create)
enqueue_consecutive
# gitaly-backup is designed to handle concurrency on its own. So we want
# to avoid entering the buggy concurrency code here when gitaly-backup
# is enabled.
if (max_concurrency <= 1 && max_storage_concurrency <= 1) || !strategy.parallel_enqueue?
return enqueue_consecutive
end
check_valid_storages!
semaphore = Concurrent::Semaphore.new(max_concurrency)
errors = Queue.new
threads = Gitlab.config.repositories.storages.keys.map do |storage|
Thread.new do
Rails.application.executor.wrap do
enqueue_storage(storage, semaphore, max_storage_concurrency: max_storage_concurrency)
rescue StandardError => e
errors << e
end
end
end
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
threads.each(&:join)
end
raise errors.pop unless errors.empty?
ensure
strategy.wait
end
@ -58,18 +32,6 @@ module Backup
attr_reader :progress, :strategy
def check_valid_storages!
repository_storage_klasses.each do |klass|
if klass.excluding_repository_storage(Gitlab.config.repositories.storages.keys).exists?
raise Error, "repositories.storages in gitlab.yml does not include all storages used by #{klass}"
end
end
end
def repository_storage_klasses
[ProjectRepository, SnippetRepository]
end
def enqueue_consecutive
enqueue_consecutive_projects
enqueue_consecutive_snippets
@ -85,50 +47,6 @@ module Backup
Snippet.find_each(batch_size: 1000) { |snippet| enqueue_snippet(snippet) }
end
def enqueue_storage(storage, semaphore, max_storage_concurrency:)
errors = Queue.new
queue = InterlockSizedQueue.new(1)
threads = Array.new(max_storage_concurrency) do
Thread.new do
Rails.application.executor.wrap do
while container = queue.pop
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
semaphore.acquire
end
begin
enqueue_container(container)
rescue StandardError => e
errors << e
break
ensure
semaphore.release
end
end
end
end
end
enqueue_records_for_storage(storage, queue, errors)
raise errors.pop unless errors.empty?
ensure
queue.close
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
threads.each(&:join)
end
end
def enqueue_container(container)
case container
when Project
enqueue_project(container)
when Snippet
enqueue_snippet(container)
end
end
def enqueue_project(project)
strategy.enqueue(project, Gitlab::GlRepository::PROJECT)
strategy.enqueue(project, Gitlab::GlRepository::WIKI)
@ -139,32 +57,10 @@ module Backup
strategy.enqueue(snippet, Gitlab::GlRepository::SNIPPET)
end
def enqueue_records_for_storage(storage, queue, errors)
records_to_enqueue(storage).each do |relation|
relation.find_each(batch_size: 100) do |project|
break unless errors.empty?
queue.push(project)
end
end
end
def records_to_enqueue(storage)
[projects_in_storage(storage), snippets_in_storage(storage)]
end
def projects_in_storage(storage)
project_relation.id_in(ProjectRepository.for_repository_storage(storage).select(:project_id))
end
def project_relation
Project.includes(:route, :group, namespace: :owner)
end
def snippets_in_storage(storage)
Snippet.id_in(SnippetRepository.for_repository_storage(storage).select(:snippet_id))
end
def restore_object_pools
PoolRepository.includes(:source_project).find_each do |pool|
progress.puts " - Object pool #{pool.disk_path}..."
@ -199,24 +95,6 @@ module Backup
Snippet.id_in(invalid_snippets).delete_all
end
class InterlockSizedQueue < SizedQueue
extend ::Gitlab::Utils::Override
override :pop
def pop(*)
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
super
end
end
override :push
def push(*)
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
super
end
end
end
end
end

View File

@ -2,6 +2,9 @@
module Gitlab
module Database
DATABASE_NAMES = %w[main ci].freeze
MAIN_DATABASE_NAME = 'main'
CI_DATABASE_NAME = 'ci'
# This constant is used when renaming tables concurrently.

View File

@ -0,0 +1,56 @@
# frozen_string_literal: true
# The purpose of this code is to transform legacy `database.yml`
# into a `database.yml` containing `main:` as a name of a first database
#
# This should be removed once all places using legacy `database.yml`
# are fixed. The likely moment to remove this check is the %14.0.
#
# This converts the following syntax:
#
# production:
# adapter: postgresql
# database: gitlabhq_production
# username: git
# password: "secure password"
# host: localhost
#
# Into:
#
# production:
# main:
# adapter: postgresql
# database: gitlabhq_production
# username: git
# password: "secure password"
# host: localhost
#
module Gitlab
module Patch
module LegacyDatabaseConfig
extend ActiveSupport::Concern
prepended do
attr_reader :uses_legacy_database_config
end
def database_configuration
@uses_legacy_database_config = false # rubocop:disable Gitlab/ModuleWithInstanceVariables
super.to_h do |env, configs|
# This check is taken from Rails where the transformation
# of a flat database.yml is done into `primary:`
# https://github.com/rails/rails/blob/v6.1.4/activerecord/lib/active_record/database_configurations.rb#L169
if configs.is_a?(Hash) && !configs.all? { |_, v| v.is_a?(Hash) }
configs = { "main" => configs }
@uses_legacy_database_config = true # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
[env, configs]
end
end
end
end
end

View File

@ -102,19 +102,10 @@ namespace :gitlab do
task create: :gitlab_environment do
puts_time "Dumping repositories ...".color(:blue)
max_concurrency = ENV.fetch('GITLAB_BACKUP_MAX_CONCURRENCY', 1).to_i
max_storage_concurrency = ENV.fetch('GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY', 1).to_i
if ENV["SKIP"] && ENV["SKIP"].include?("repositories")
puts_time "[SKIPPED]".color(:cyan)
elsif max_concurrency < 1 || max_storage_concurrency < 1
puts "GITLAB_BACKUP_MAX_CONCURRENCY and GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY must have a value of at least 1".color(:red)
exit 1
else
Backup::Repositories.new(progress, strategy: repository_backup_strategy).dump(
max_concurrency: max_concurrency,
max_storage_concurrency: max_storage_concurrency
)
Backup::Repositories.new(progress, strategy: repository_backup_strategy).dump
puts_time "done".color(:green)
end
end
@ -299,13 +290,9 @@ namespace :gitlab do
end
def repository_backup_strategy
if Feature.enabled?(:gitaly_backup, default_enabled: :yaml)
max_concurrency = ENV['GITLAB_BACKUP_MAX_CONCURRENCY'].presence
max_storage_concurrency = ENV['GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY'].presence
Backup::GitalyBackup.new(progress, parallel: max_concurrency, parallel_storage: max_storage_concurrency)
else
Backup::GitalyRpcBackup.new(progress)
end
max_concurrency = ENV['GITLAB_BACKUP_MAX_CONCURRENCY'].presence
max_storage_concurrency = ENV['GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY'].presence
Backup::GitalyBackup.new(progress, parallel: max_concurrency, parallel_storage: max_storage_concurrency)
end
end
# namespace end: backup

View File

@ -621,9 +621,6 @@ msgstr ""
msgid "%{issueType} actions"
msgstr ""
msgid "%{issuesCount} issues with a limit of %{maxIssueCount}"
msgstr ""
msgid "%{issuesSize} with a limit of %{maxIssueCount}"
msgstr ""
@ -20232,9 +20229,6 @@ msgstr ""
msgid "Loading functions timed out. Please reload the page to try again."
msgstr ""
msgid "Loading issues"
msgstr ""
msgid "Loading more"
msgstr ""
@ -30904,9 +30898,6 @@ msgstr ""
msgid "Showing %{pageSize} of %{total} %{issuableType}"
msgstr ""
msgid "Showing %{pageSize} of %{total} issues"
msgstr ""
msgid "Showing all epics"
msgstr ""
@ -31327,9 +31318,6 @@ msgstr ""
msgid "Something went wrong while obtaining the Let's Encrypt certificate."
msgstr ""
msgid "Something went wrong while performing the action."
msgstr ""
msgid "Something went wrong while promoting the issue to an epic. Please try again."
msgstr ""

View File

@ -115,7 +115,7 @@
"codesandbox-api": "0.0.23",
"compression-webpack-plugin": "^5.0.2",
"copy-webpack-plugin": "^6.4.1",
"core-js": "^3.16.4",
"core-js": "^3.17.1",
"cron-validator": "^1.1.1",
"cropper": "^2.3.0",
"css-loader": "^2.1.1",

View File

@ -1,274 +0,0 @@
/* global List */
/* global ListIssue */
import MockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
import BoardList from '~/boards/components/board_list_deprecated.vue';
import eventHub from '~/boards/eventhub';
import store from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store';
import axios from '~/lib/utils/axios_utils';
import '~/boards/models/issue';
import '~/boards/models/list';
import { listObj, boardsMockInterceptor } from './mock_data';
const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listProps = {} }) => {
const el = document.createElement('div');
document.body.appendChild(el);
const mock = new MockAdapter(axios);
mock.onAny().reply(boardsMockInterceptor);
boardsStore.create();
const BoardListComp = Vue.extend(BoardList);
const list = new List({ ...listObj, ...listProps });
const issue = new ListIssue({
title: 'Testing',
id: 1,
iid: 1,
confidential: false,
labels: [],
assignees: [],
...listIssueProps,
});
if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) {
list.issuesSize = 1;
}
list.issues.push(issue);
const component = new BoardListComp({
el,
store,
propsData: {
disabled: false,
list,
issues: list.issues,
...componentProps,
},
provide: {
groupId: null,
rootPath: '/',
},
}).$mount();
Vue.nextTick(() => {
done();
});
return { component, mock };
};
describe('Board list component', () => {
let mock;
let component;
let getIssues;
function generateIssues(compWrapper) {
for (let i = 1; i < 20; i += 1) {
const issue = { ...compWrapper.list.issues[0] };
issue.id += i;
compWrapper.list.issues.push(issue);
}
}
describe('When Expanded', () => {
beforeEach((done) => {
getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {}));
({ mock, component } = createComponent({ done }));
});
afterEach(() => {
mock.restore();
component.$destroy();
});
it('loads first page of issues', () => {
return waitForPromises().then(() => {
expect(getIssues).toHaveBeenCalled();
});
});
it('renders component', () => {
expect(component.$el.classList.contains('board-list-component')).toBe(true);
});
it('renders loading icon', () => {
component.list.loading = true;
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.board-list-loading')).not.toBeNull();
});
});
it('renders issues', () => {
expect(component.$el.querySelectorAll('.board-card').length).toBe(1);
});
it('sets data attribute with issue id', () => {
expect(component.$el.querySelector('.board-card').getAttribute('data-issue-id')).toBe('1');
});
it('shows new issue form', () => {
component.toggleForm();
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull();
expect(component.$el.querySelector('.is-smaller')).not.toBeNull();
});
});
it('shows new issue form after eventhub event', () => {
eventHub.$emit(`toggle-issue-form-${component.list.id}`);
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull();
expect(component.$el.querySelector('.is-smaller')).not.toBeNull();
});
});
it('does not show new issue form for closed list', () => {
component.list.type = 'closed';
component.toggleForm();
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.board-new-issue-form')).toBeNull();
});
});
it('shows count list item', () => {
component.showCount = true;
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.board-list-count')).not.toBeNull();
expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe(
'Showing all issues',
);
});
});
it('sets data attribute with invalid id', () => {
component.showCount = true;
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.board-list-count').getAttribute('data-issue-id')).toBe(
'-1',
);
});
});
it('shows how many more issues to load', () => {
component.showCount = true;
component.list.issuesSize = 20;
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe(
'Showing 1 of 20 issues',
);
});
});
it('loads more issues after scrolling', () => {
jest.spyOn(component.list, 'nextPage').mockImplementation(() => {});
generateIssues(component);
component.$refs.list.dispatchEvent(new Event('scroll'));
return waitForPromises().then(() => {
expect(component.list.nextPage).toHaveBeenCalled();
});
});
it('does not load issues if already loading', () => {
component.list.nextPage = jest
.spyOn(component.list, 'nextPage')
.mockReturnValue(new Promise(() => {}));
component.onScroll();
component.onScroll();
return waitForPromises().then(() => {
expect(component.list.nextPage).toHaveBeenCalledTimes(1);
});
});
it('shows loading more spinner', () => {
component.showCount = true;
component.list.loadingMore = true;
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.board-list-count .gl-spinner')).not.toBeNull();
});
});
});
describe('When Collapsed', () => {
beforeEach((done) => {
getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {}));
({ mock, component } = createComponent({
done,
listProps: { type: 'closed', collapsed: true, issuesSize: 50 },
}));
generateIssues(component);
component.scrollHeight = jest.spyOn(component, 'scrollHeight').mockReturnValue(0);
});
afterEach(() => {
mock.restore();
component.$destroy();
});
it('does not load all issues', () => {
return waitForPromises().then(() => {
// Initial getIssues from list constructor
expect(getIssues).toHaveBeenCalledTimes(1);
});
});
});
describe('max issue count warning', () => {
beforeEach((done) => {
({ mock, component } = createComponent({
done,
listProps: { type: 'closed', collapsed: true, issuesSize: 50 },
}));
});
afterEach(() => {
mock.restore();
component.$destroy();
});
describe('when issue count exceeds max issue count', () => {
it('sets background to bg-danger-100', () => {
component.list.issuesSize = 4;
component.list.maxIssueCount = 3;
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.bg-danger-100')).not.toBeNull();
});
});
});
describe('when list issue count does NOT exceed list max issue count', () => {
it('does not sets background to bg-danger-100', () => {
component.list.issuesSize = 2;
component.list.maxIssueCount = 3;
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.bg-danger-100')).toBeNull();
});
});
});
describe('when list max issue count is 0', () => {
it('does not sets background to bg-danger-100', () => {
component.list.maxIssueCount = 0;
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.bg-danger-100')).toBeNull();
});
});
});
});
});

View File

@ -1,211 +0,0 @@
/* global List */
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
import Vuex from 'vuex';
import boardNewIssue from '~/boards/components/board_new_issue_deprecated.vue';
import boardsStore from '~/boards/stores/boards_store';
import axios from '~/lib/utils/axios_utils';
import '~/boards/models/list';
import { listObj, boardsMockInterceptor } from './mock_data';
Vue.use(Vuex);
describe('Issue boards new issue form', () => {
let wrapper;
let vm;
let list;
let mock;
let newIssueMock;
const promiseReturn = {
data: {
iid: 100,
},
};
const submitIssue = () => {
const dummySubmitEvent = {
preventDefault() {},
};
wrapper.vm.$refs.submitButton = wrapper.find({ ref: 'submitButton' });
return wrapper.vm.submit(dummySubmitEvent);
};
beforeEach(() => {
const BoardNewIssueComp = Vue.extend(boardNewIssue);
mock = new MockAdapter(axios);
mock.onAny().reply(boardsMockInterceptor);
boardsStore.create();
list = new List(listObj);
newIssueMock = Promise.resolve(promiseReturn);
jest.spyOn(list, 'newIssue').mockImplementation(() => newIssueMock);
const store = new Vuex.Store({
getters: { isGroupBoard: () => false },
});
wrapper = mount(BoardNewIssueComp, {
propsData: {
disabled: false,
list,
},
store,
provide: {
groupId: null,
},
});
vm = wrapper.vm;
return Vue.nextTick();
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
it('calls submit if submit button is clicked', () => {
jest.spyOn(wrapper.vm, 'submit').mockImplementation();
vm.title = 'Testing Title';
return Vue.nextTick()
.then(submitIssue)
.then(() => {
expect(wrapper.vm.submit).toHaveBeenCalled();
});
});
it('disables submit button if title is empty', () => {
expect(wrapper.find({ ref: 'submitButton' }).props().disabled).toBe(true);
});
it('enables submit button if title is not empty', () => {
wrapper.setData({ title: 'Testing Title' });
return Vue.nextTick().then(() => {
expect(wrapper.find({ ref: 'input' }).element.value).toBe('Testing Title');
expect(wrapper.find({ ref: 'submitButton' }).props().disabled).toBe(false);
});
});
it('clears title after clicking cancel', () => {
wrapper.find({ ref: 'cancelButton' }).trigger('click');
return Vue.nextTick().then(() => {
expect(vm.title).toBe('');
});
});
it('does not create new issue if title is empty', () => {
return submitIssue().then(() => {
expect(list.newIssue).not.toHaveBeenCalled();
});
});
describe('submit success', () => {
it('creates new issue', () => {
wrapper.setData({ title: 'create issue' });
return Vue.nextTick()
.then(submitIssue)
.then(() => {
expect(list.newIssue).toHaveBeenCalled();
});
});
it('enables button after submit', () => {
jest.spyOn(wrapper.vm, 'submit').mockImplementation();
wrapper.setData({ title: 'create issue' });
return Vue.nextTick()
.then(submitIssue)
.then(() => {
expect(wrapper.vm.$refs.submitButton.props().disabled).toBe(false);
});
});
it('clears title after submit', () => {
wrapper.setData({ title: 'create issue' });
return Vue.nextTick()
.then(submitIssue)
.then(() => {
expect(vm.title).toBe('');
});
});
it('sets detail issue after submit', () => {
expect(boardsStore.detail.issue.title).toBe(undefined);
wrapper.setData({ title: 'create issue' });
return Vue.nextTick()
.then(submitIssue)
.then(() => {
expect(boardsStore.detail.issue.title).toBe('create issue');
});
});
it('sets detail list after submit', () => {
wrapper.setData({ title: 'create issue' });
return Vue.nextTick()
.then(submitIssue)
.then(() => {
expect(boardsStore.detail.list.id).toBe(list.id);
});
});
it('sets detail weight after submit', () => {
boardsStore.weightFeatureAvailable = true;
wrapper.setData({ title: 'create issue' });
return Vue.nextTick()
.then(submitIssue)
.then(() => {
expect(boardsStore.detail.list.weight).toBe(list.weight);
});
});
it('does not set detail weight after submit', () => {
boardsStore.weightFeatureAvailable = false;
wrapper.setData({ title: 'create issue' });
return Vue.nextTick()
.then(submitIssue)
.then(() => {
expect(boardsStore.detail.list.weight).toBe(list.weight);
});
});
});
describe('submit error', () => {
beforeEach(() => {
newIssueMock = Promise.reject(new Error('My hovercraft is full of eels!'));
vm.title = 'error';
});
it('removes issue', () => {
const lengthBefore = list.issues.length;
return Vue.nextTick()
.then(submitIssue)
.then(() => {
expect(list.issues.length).toBe(lengthBefore);
});
});
it('shows error', () => {
return Vue.nextTick()
.then(submitIssue)
.then(() => {
expect(vm.error).toBe(true);
});
});
});
});

View File

@ -1,181 +0,0 @@
/* global List */
/* global ListAssignee */
/* global ListLabel */
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import BoardCardDeprecated from '~/boards/components/board_card_deprecated.vue';
import issueCardInner from '~/boards/components/issue_card_inner_deprecated.vue';
import store from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store';
import axios from '~/lib/utils/axios_utils';
import sidebarEventHub from '~/sidebar/event_hub';
import '~/boards/models/label';
import '~/boards/models/assignee';
import '~/boards/models/list';
import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data';
describe('BoardCard', () => {
let wrapper;
let mock;
let list;
const findIssueCardInner = () => wrapper.find(issueCardInner);
const findUserAvatarLink = () => wrapper.find(userAvatarLink);
// this particular mount component needs to be used after the root beforeEach because it depends on list being initialized
const mountComponent = (propsData) => {
wrapper = mount(BoardCardDeprecated, {
stubs: {
issueCardInner,
},
store,
propsData: {
list,
issue: list.issues[0],
disabled: false,
index: 0,
...propsData,
},
provide: {
groupId: null,
rootPath: '/',
scopedLabelsAvailable: false,
},
});
};
const setupData = async () => {
list = new List(listObj);
boardsStore.create();
boardsStore.detail.issue = {};
const label1 = new ListLabel({
id: 3,
title: 'testing 123',
color: '#000cff',
text_color: 'white',
description: 'test',
});
await waitForPromises();
list.issues[0].labels.push(label1);
};
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onAny().reply(boardsMockInterceptor);
setMockEndpoints();
return setupData();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
list = null;
mock.restore();
});
it('when details issue is empty does not show the element', () => {
mountComponent();
expect(wrapper.find('[data-testid="board_card"').classes()).not.toContain('is-active');
});
it('when detailIssue is equal to card issue shows the element', () => {
[boardsStore.detail.issue] = list.issues;
mountComponent();
expect(wrapper.classes()).toContain('is-active');
});
it('when multiSelect does not contain issue removes multi select class', () => {
mountComponent();
expect(wrapper.classes()).not.toContain('multi-select');
});
it('when multiSelect contain issue add multi select class', () => {
boardsStore.multiSelect.list = [list.issues[0]];
mountComponent();
expect(wrapper.classes()).toContain('multi-select');
});
it('adds user-can-drag class if not disabled', () => {
mountComponent();
expect(wrapper.classes()).toContain('user-can-drag');
});
it('does not add user-can-drag class disabled', () => {
mountComponent({ disabled: true });
expect(wrapper.classes()).not.toContain('user-can-drag');
});
it('does not add disabled class', () => {
mountComponent();
expect(wrapper.classes()).not.toContain('is-disabled');
});
it('adds disabled class is disabled is true', () => {
mountComponent({ disabled: true });
expect(wrapper.classes()).toContain('is-disabled');
});
describe('mouse events', () => {
it('does not set detail issue if showDetail is false', () => {
mountComponent();
expect(boardsStore.detail.issue).toEqual({});
});
it('does not set detail issue if link is clicked', () => {
mountComponent();
findIssueCardInner().find('a').trigger('mouseup');
expect(boardsStore.detail.issue).toEqual({});
});
it('does not set detail issue if img is clicked', () => {
mountComponent({
issue: {
...list.issues[0],
assignees: [
new ListAssignee({
id: 1,
name: 'testing 123',
username: 'test',
avatar: 'test_image',
}),
],
},
});
findUserAvatarLink().trigger('mouseup');
expect(boardsStore.detail.issue).toEqual({});
});
it('does not set detail issue if showDetail is false after mouseup', () => {
mountComponent();
wrapper.trigger('mouseup');
expect(boardsStore.detail.issue).toEqual({});
});
});
describe('sidebarHub events', () => {
it('it does not closes all sidebars before showing an issue if an issue is opened', () => {
jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {});
const [issue] = list.issues;
boardsStore.detail.issue = issue;
mountComponent();
wrapper.trigger('mousedown');
expect(sidebarEventHub.$emit).not.toHaveBeenCalledWith('sidebar.closeAll');
});
});
});

View File

@ -1,154 +0,0 @@
/* global List */
/* global ListLabel */
import { createLocalVue, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import Vuex from 'vuex';
import waitForPromises from 'helpers/wait_for_promises';
import '~/boards/models/label';
import '~/boards/models/assignee';
import '~/boards/models/list';
import BoardCardLayout from '~/boards/components/board_card_layout_deprecated.vue';
import issueCardInner from '~/boards/components/issue_card_inner_deprecated.vue';
import { ISSUABLE } from '~/boards/constants';
import boardsVuexStore from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store';
import axios from '~/lib/utils/axios_utils';
import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data';
describe('Board card layout', () => {
let wrapper;
let mock;
let list;
let store;
const localVue = createLocalVue();
localVue.use(Vuex);
const createStore = ({ getters = {}, actions = {} } = {}) => {
store = new Vuex.Store({
...boardsVuexStore,
actions,
getters,
});
};
// this particular mount component needs to be used after the root beforeEach because it depends on list being initialized
const mountComponent = ({ propsData = {}, provide = {} } = {}) => {
wrapper = shallowMount(BoardCardLayout, {
localVue,
stubs: {
issueCardInner,
},
store,
propsData: {
list,
issue: list.issues[0],
disabled: false,
index: 0,
...propsData,
},
provide: {
groupId: null,
rootPath: '/',
scopedLabelsAvailable: false,
...provide,
},
});
};
const setupData = () => {
list = new List(listObj);
boardsStore.create();
boardsStore.detail.issue = {};
const label1 = new ListLabel({
id: 3,
title: 'testing 123',
color: '#000cff',
text_color: 'white',
description: 'test',
});
return waitForPromises().then(() => {
list.issues[0].labels.push(label1);
});
};
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onAny().reply(boardsMockInterceptor);
setMockEndpoints();
return setupData();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
list = null;
mock.restore();
});
describe('mouse events', () => {
it('sets showDetail to true on mousedown', async () => {
createStore();
mountComponent();
wrapper.trigger('mousedown');
await wrapper.vm.$nextTick();
expect(wrapper.vm.showDetail).toBe(true);
});
it('sets showDetail to false on mousemove', async () => {
createStore();
mountComponent();
wrapper.trigger('mousedown');
await wrapper.vm.$nextTick();
expect(wrapper.vm.showDetail).toBe(true);
wrapper.trigger('mousemove');
await wrapper.vm.$nextTick();
expect(wrapper.vm.showDetail).toBe(false);
});
it("calls 'setActiveId'", async () => {
const setActiveId = jest.fn();
createStore({
actions: {
setActiveId,
},
});
mountComponent();
wrapper.trigger('mouseup');
await wrapper.vm.$nextTick();
expect(setActiveId).toHaveBeenCalledTimes(1);
expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), {
id: list.issues[0].id,
sidebarType: ISSUABLE,
});
});
it("calls 'setActiveId' when epic swimlanes is active", async () => {
const setActiveId = jest.fn();
const isSwimlanesOn = () => true;
createStore({
getters: { isSwimlanesOn },
actions: {
setActiveId,
},
});
mountComponent();
wrapper.trigger('mouseup');
await wrapper.vm.$nextTick();
expect(setActiveId).toHaveBeenCalledTimes(1);
expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), {
id: list.issues[0].id,
sidebarType: ISSUABLE,
});
});
});
});

View File

@ -1,106 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import Vue, { nextTick } from 'vue';
import { TEST_HOST } from 'helpers/test_constants';
import { listObj } from 'jest/boards/mock_data';
import Board from '~/boards/components/board_column_deprecated.vue';
import { ListType } from '~/boards/constants';
import List from '~/boards/models/list';
import axios from '~/lib/utils/axios_utils';
describe('Board Column Component', () => {
let wrapper;
let axiosMock;
beforeEach(() => {
window.gon = {};
axiosMock = new AxiosMockAdapter(axios);
axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] });
});
afterEach(() => {
axiosMock.restore();
wrapper.destroy();
localStorage.clear();
});
const createComponent = ({
listType = ListType.backlog,
collapsed = false,
highlighted = false,
withLocalStorage = true,
} = {}) => {
const boardId = '1';
const listMock = {
...listObj,
list_type: listType,
highlighted,
collapsed,
};
if (listType === ListType.assignee) {
delete listMock.label;
listMock.user = {};
}
// Making List reactive
const list = Vue.observable(new List(listMock));
if (withLocalStorage) {
localStorage.setItem(
`boards.${boardId}.${list.type}.${list.id}.expanded`,
(!collapsed).toString(),
);
}
wrapper = shallowMount(Board, {
propsData: {
boardId,
disabled: false,
list,
},
provide: {
boardId,
},
});
};
const isExpandable = () => wrapper.classes('is-expandable');
const isCollapsed = () => wrapper.classes('is-collapsed');
describe('Given different list types', () => {
it('is expandable when List Type is `backlog`', () => {
createComponent({ listType: ListType.backlog });
expect(isExpandable()).toBe(true);
});
});
describe('expanded / collapsed column', () => {
it('has class is-collapsed when list is collapsed', () => {
createComponent({ collapsed: false });
expect(wrapper.vm.list.isExpanded).toBe(true);
});
it('does not have class is-collapsed when list is expanded', () => {
createComponent({ collapsed: true });
expect(isCollapsed()).toBe(true);
});
});
describe('highlighting', () => {
it('scrolls to column when highlighted', async () => {
createComponent({ highlighted: true });
await nextTick();
expect(wrapper.element.scrollIntoView).toHaveBeenCalled();
});
});
});

View File

@ -1,174 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
import { TEST_HOST } from 'helpers/test_constants';
import { listObj } from 'jest/boards/mock_data';
import BoardListHeader from '~/boards/components/board_list_header_deprecated.vue';
import { ListType } from '~/boards/constants';
import List from '~/boards/models/list';
import axios from '~/lib/utils/axios_utils';
describe('Board List Header Component', () => {
let wrapper;
let axiosMock;
beforeEach(() => {
window.gon = {};
axiosMock = new AxiosMockAdapter(axios);
axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] });
});
afterEach(() => {
axiosMock.restore();
wrapper.destroy();
localStorage.clear();
});
const createComponent = ({
listType = ListType.backlog,
collapsed = false,
withLocalStorage = true,
currentUserId = 1,
} = {}) => {
const boardId = '1';
const listMock = {
...listObj,
list_type: listType,
collapsed,
};
if (listType === ListType.assignee) {
delete listMock.label;
listMock.user = {};
}
// Making List reactive
const list = Vue.observable(new List(listMock));
if (withLocalStorage) {
localStorage.setItem(
`boards.${boardId}.${list.type}.${list.id}.expanded`,
(!collapsed).toString(),
);
}
wrapper = shallowMount(BoardListHeader, {
propsData: {
disabled: false,
list,
},
provide: {
boardId,
currentUserId,
},
});
};
const isCollapsed = () => !wrapper.props().list.isExpanded;
const isExpanded = () => wrapper.vm.list.isExpanded;
const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' });
const findCaret = () => wrapper.find('.board-title-caret');
describe('Add issue button', () => {
const hasNoAddButton = [ListType.closed];
const hasAddButton = [
ListType.backlog,
ListType.label,
ListType.milestone,
ListType.iteration,
ListType.assignee,
];
it.each(hasNoAddButton)('does not render when List Type is `%s`', (listType) => {
createComponent({ listType });
expect(findAddIssueButton().exists()).toBe(false);
});
it.each(hasAddButton)('does render when List Type is `%s`', (listType) => {
createComponent({ listType });
expect(findAddIssueButton().exists()).toBe(true);
});
it('has a test for each list type', () => {
Object.values(ListType).forEach((value) => {
expect([...hasAddButton, ...hasNoAddButton]).toContain(value);
});
});
it('does not render when logged out', () => {
createComponent({
currentUserId: null,
});
expect(findAddIssueButton().exists()).toBe(false);
});
});
describe('expanding / collapsing the column', () => {
it('does not collapse when clicking the header', () => {
createComponent();
expect(isCollapsed()).toBe(false);
wrapper.find('[data-testid="board-list-header"]').trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(isCollapsed()).toBe(false);
});
});
it('collapses expanded Column when clicking the collapse icon', () => {
createComponent();
expect(isExpanded()).toBe(true);
findCaret().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(isCollapsed()).toBe(true);
});
});
it('expands collapsed Column when clicking the expand icon', () => {
createComponent({ collapsed: true });
expect(isCollapsed()).toBe(true);
findCaret().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(isCollapsed()).toBe(false);
});
});
it("when logged in it calls list update and doesn't set localStorage", () => {
jest.spyOn(List.prototype, 'update');
createComponent({ withLocalStorage: false });
findCaret().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.list.update).toHaveBeenCalledTimes(1);
expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null);
});
});
it("when logged out it doesn't call list update and sets localStorage", () => {
jest.spyOn(List.prototype, 'update');
createComponent({ currentUserId: null });
findCaret().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.list.update).not.toHaveBeenCalled();
expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded()));
});
});
});
});

View File

@ -1,214 +0,0 @@
import { GlDropdown, GlLoadingIcon, GlDropdownSectionHeader } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { TEST_HOST } from 'spec/test_constants';
import BoardsSelector from '~/boards/components/boards_selector_deprecated.vue';
import boardsStore from '~/boards/stores/boards_store';
const throttleDuration = 1;
function boardGenerator(n) {
return new Array(n).fill().map((board, index) => {
const id = `${index}`;
const name = `board${id}`;
return {
id,
name,
};
});
}
describe('BoardsSelector', () => {
let wrapper;
let allBoardsResponse;
let recentBoardsResponse;
const boards = boardGenerator(20);
const recentBoards = boardGenerator(5);
const fillSearchBox = (filterTerm) => {
const searchBox = wrapper.find({ ref: 'searchBox' });
const searchBoxInput = searchBox.find('input');
searchBoxInput.setValue(filterTerm);
searchBoxInput.trigger('input');
};
const getDropdownItems = () => wrapper.findAll('.js-dropdown-item');
const getDropdownHeaders = () => wrapper.findAll(GlDropdownSectionHeader);
const getLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findDropdown = () => wrapper.find(GlDropdown);
beforeEach(() => {
const $apollo = {
queries: {
boards: {
loading: false,
},
},
};
boardsStore.setEndpoints({
boardsEndpoint: '',
recentBoardsEndpoint: '',
listsEndpoint: '',
bulkUpdatePath: '',
boardId: '',
});
allBoardsResponse = Promise.resolve({
data: {
group: {
boards: {
edges: boards.map((board) => ({ node: board })),
},
},
},
});
recentBoardsResponse = Promise.resolve({
data: recentBoards,
});
boardsStore.allBoards = jest.fn(() => allBoardsResponse);
boardsStore.recentBoards = jest.fn(() => recentBoardsResponse);
wrapper = mount(BoardsSelector, {
propsData: {
throttleDuration,
currentBoard: {
id: 1,
name: 'Development',
milestone_id: null,
weight: null,
assignee_id: null,
labels: [],
},
boardBaseUrl: `${TEST_HOST}/board/base/url`,
hasMissingBoards: false,
canAdminBoard: true,
multipleIssueBoardsAvailable: true,
labelsPath: `${TEST_HOST}/labels/path`,
labelsWebUrl: `${TEST_HOST}/labels`,
projectId: 42,
groupId: 19,
scopedIssueBoardFeatureEnabled: true,
weights: [],
},
mocks: { $apollo },
attachTo: document.body,
});
wrapper.vm.$apollo.addSmartQuery = jest.fn((_, options) => {
wrapper.setData({
[options.loadingKey]: true,
});
});
// Emits gl-dropdown show event to simulate the dropdown is opened at initialization time
findDropdown().vm.$emit('show');
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('loading', () => {
// we are testing loading state, so don't resolve responses until after the tests
afterEach(() => {
return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick());
});
it('shows loading spinner', () => {
expect(getDropdownHeaders()).toHaveLength(0);
expect(getDropdownItems()).toHaveLength(0);
expect(getLoadingIcon().exists()).toBe(true);
});
});
describe('loaded', () => {
beforeEach(async () => {
await wrapper.setData({
loadingBoards: false,
});
return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick());
});
it('hides loading spinner', () => {
expect(getLoadingIcon().exists()).toBe(false);
});
describe('filtering', () => {
beforeEach(() => {
wrapper.setData({
boards,
});
return nextTick();
});
it('shows all boards without filtering', () => {
expect(getDropdownItems()).toHaveLength(boards.length + recentBoards.length);
});
it('shows only matching boards when filtering', () => {
const filterTerm = 'board1';
const expectedCount = boards.filter((board) => board.name.includes(filterTerm)).length;
fillSearchBox(filterTerm);
return nextTick().then(() => {
expect(getDropdownItems()).toHaveLength(expectedCount);
});
});
it('shows message if there are no matching boards', () => {
fillSearchBox('does not exist');
return nextTick().then(() => {
expect(getDropdownItems()).toHaveLength(0);
expect(wrapper.text().includes('No matching boards found')).toBe(true);
});
});
});
describe('recent boards section', () => {
it('shows only when boards are greater than 10', () => {
wrapper.setData({
boards,
});
return nextTick().then(() => {
expect(getDropdownHeaders()).toHaveLength(2);
});
});
it('does not show when boards are less than 10', () => {
wrapper.setData({
boards: boards.slice(0, 5),
});
return nextTick().then(() => {
expect(getDropdownHeaders()).toHaveLength(0);
});
});
it('does not show when recentBoards api returns empty array', () => {
wrapper.setData({
recentBoards: [],
});
return nextTick().then(() => {
expect(getDropdownHeaders()).toHaveLength(0);
});
});
it('does not show when search is active', () => {
fillSearchBox('Random string');
return nextTick().then(() => {
expect(getDropdownHeaders()).toHaveLength(0);
});
});
});
});
});

View File

@ -1,64 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import IssueTimeEstimate from '~/boards/components/issue_time_estimate_deprecated.vue';
import boardsStore from '~/boards/stores/boards_store';
describe('Issue Time Estimate component', () => {
let wrapper;
beforeEach(() => {
boardsStore.create();
});
afterEach(() => {
wrapper.destroy();
});
describe('when limitToHours is false', () => {
beforeEach(() => {
boardsStore.timeTracking.limitToHours = false;
wrapper = shallowMount(IssueTimeEstimate, {
propsData: {
estimate: 374460,
},
});
});
it('renders the correct time estimate', () => {
expect(wrapper.find('time').text().trim()).toEqual('2w 3d 1m');
});
it('renders expanded time estimate in tooltip', () => {
expect(wrapper.find('.js-issue-time-estimate').text()).toContain('2 weeks 3 days 1 minute');
});
it('prevents tooltip xss', (done) => {
const alertSpy = jest.spyOn(window, 'alert');
wrapper.setProps({ estimate: 'Foo <script>alert("XSS")</script>' });
wrapper.vm.$nextTick(() => {
expect(alertSpy).not.toHaveBeenCalled();
expect(wrapper.find('time').text().trim()).toEqual('0m');
expect(wrapper.find('.js-issue-time-estimate').text()).toContain('0m');
done();
});
});
});
describe('when limitToHours is true', () => {
beforeEach(() => {
boardsStore.timeTracking.limitToHours = true;
wrapper = shallowMount(IssueTimeEstimate, {
propsData: {
estimate: 374460,
},
});
});
it('renders the correct time estimate', () => {
expect(wrapper.find('time').text().trim()).toEqual('104h 1m');
});
it('renders expanded time estimate in tooltip', () => {
expect(wrapper.find('.js-issue-time-estimate').text()).toContain('104 hours 1 minute');
});
});
});

View File

@ -1,332 +0,0 @@
/* global ListAssignee, ListLabel, ListIssue */
import { GlLabel } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { range } from 'lodash';
import '~/boards/models/label';
import '~/boards/models/assignee';
import '~/boards/models/issue';
import '~/boards/models/list';
import IssueCardInner from '~/boards/components/issue_card_inner_deprecated.vue';
import store from '~/boards/stores';
import { listObj } from './mock_data';
describe('Issue card component', () => {
const user = new ListAssignee({
id: 1,
name: 'testing 123',
username: 'test',
avatar: 'test_image',
});
const label1 = new ListLabel({
id: 3,
title: 'testing 123',
color: '#000CFF',
text_color: 'white',
description: 'test',
});
let wrapper;
let issue;
let list;
beforeEach(() => {
list = { ...listObj, type: 'label' };
issue = new ListIssue({
title: 'Testing',
id: 1,
iid: 1,
confidential: false,
labels: [list.label],
assignees: [],
reference_path: '#1',
real_path: '/test/1',
weight: 1,
});
wrapper = mount(IssueCardInner, {
propsData: {
list,
issue,
},
store,
stubs: {
GlLabel: true,
},
provide: {
groupId: null,
rootPath: '/',
},
});
});
it('renders issue title', () => {
expect(wrapper.find('.board-card-title').text()).toContain(issue.title);
});
it('includes issue base in link', () => {
expect(wrapper.find('.board-card-title a').attributes('href')).toContain('/test');
});
it('includes issue title on link', () => {
expect(wrapper.find('.board-card-title a').attributes('title')).toBe(issue.title);
});
it('does not render confidential icon', () => {
expect(wrapper.find('.confidential-icon').exists()).toBe(false);
});
it('does not render blocked icon', () => {
expect(wrapper.find('.issue-blocked-icon').exists()).toBe(false);
});
it('renders confidential icon', (done) => {
wrapper.setProps({
issue: {
...wrapper.props('issue'),
confidential: true,
},
});
wrapper.vm.$nextTick(() => {
expect(wrapper.find('.confidential-icon').exists()).toBe(true);
done();
});
});
it('renders issue ID with #', () => {
expect(wrapper.find('.board-card-number').text()).toContain(`#${issue.id}`);
});
describe('assignee', () => {
it('does not render assignee', () => {
expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(false);
});
describe('exists', () => {
beforeEach((done) => {
wrapper.setProps({
issue: {
...wrapper.props('issue'),
assignees: [user],
updateData(newData) {
Object.assign(this, newData);
},
},
});
wrapper.vm.$nextTick(done);
});
it('renders assignee', () => {
expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(true);
});
it('sets title', () => {
expect(wrapper.find('.js-assignee-tooltip').text()).toContain(`${user.name}`);
});
it('sets users path', () => {
expect(wrapper.find('.board-card-assignee a').attributes('href')).toBe('/test');
});
it('renders avatar', () => {
expect(wrapper.find('.board-card-assignee img').exists()).toBe(true);
});
it('renders the avatar using avatar_url property', (done) => {
wrapper.props('issue').updateData({
...wrapper.props('issue'),
assignees: [
{
id: '1',
name: 'test',
state: 'active',
username: 'test_name',
avatar_url: 'test_image_from_avatar_url',
},
],
});
wrapper.vm.$nextTick(() => {
expect(wrapper.find('.board-card-assignee img').attributes('src')).toBe(
'test_image_from_avatar_url?width=24',
);
done();
});
});
});
describe('assignee default avatar', () => {
beforeEach((done) => {
global.gon.default_avatar_url = 'default_avatar';
wrapper.setProps({
issue: {
...wrapper.props('issue'),
assignees: [
new ListAssignee({
id: 1,
name: 'testing 123',
username: 'test',
}),
],
},
});
wrapper.vm.$nextTick(done);
});
afterEach(() => {
global.gon.default_avatar_url = null;
});
it('displays defaults avatar if users avatar is null', () => {
expect(wrapper.find('.board-card-assignee img').exists()).toBe(true);
expect(wrapper.find('.board-card-assignee img').attributes('src')).toBe(
'default_avatar?width=24',
);
});
});
});
describe('multiple assignees', () => {
beforeEach((done) => {
wrapper.setProps({
issue: {
...wrapper.props('issue'),
assignees: [
new ListAssignee({
id: 2,
name: 'user2',
username: 'user2',
avatar: 'test_image',
}),
new ListAssignee({
id: 3,
name: 'user3',
username: 'user3',
avatar: 'test_image',
}),
new ListAssignee({
id: 4,
name: 'user4',
username: 'user4',
avatar: 'test_image',
}),
],
},
});
wrapper.vm.$nextTick(done);
});
it('renders all three assignees', () => {
expect(wrapper.findAll('.board-card-assignee .avatar').length).toEqual(3);
});
describe('more than three assignees', () => {
beforeEach((done) => {
const { assignees } = wrapper.props('issue');
assignees.push(
new ListAssignee({
id: 5,
name: 'user5',
username: 'user5',
avatar: 'test_image',
}),
);
wrapper.setProps({
issue: {
...wrapper.props('issue'),
assignees,
},
});
wrapper.vm.$nextTick(done);
});
it('renders more avatar counter', () => {
expect(wrapper.find('.board-card-assignee .avatar-counter').text().trim()).toEqual('+2');
});
it('renders two assignees', () => {
expect(wrapper.findAll('.board-card-assignee .avatar').length).toEqual(2);
});
it('renders 99+ avatar counter', (done) => {
const assignees = [
...wrapper.props('issue').assignees,
...range(5, 103).map(
(i) =>
new ListAssignee({
id: i,
name: 'name',
username: 'username',
avatar: 'test_image',
}),
),
];
wrapper.setProps({
issue: {
...wrapper.props('issue'),
assignees,
},
});
wrapper.vm.$nextTick(() => {
expect(wrapper.find('.board-card-assignee .avatar-counter').text().trim()).toEqual('99+');
done();
});
});
});
});
describe('labels', () => {
beforeEach((done) => {
issue.addLabel(label1);
wrapper.setProps({ issue: { ...issue } });
wrapper.vm.$nextTick(done);
});
it('does not render list label but renders all other labels', () => {
expect(wrapper.findAll(GlLabel).length).toBe(1);
const label = wrapper.find(GlLabel);
expect(label.props('title')).toEqual(label1.title);
expect(label.props('description')).toEqual(label1.description);
expect(label.props('backgroundColor')).toEqual(label1.color);
});
it('does not render label if label does not have an ID', (done) => {
issue.addLabel(
new ListLabel({
title: 'closed',
}),
);
wrapper.setProps({ issue: { ...issue } });
wrapper.vm
.$nextTick()
.then(() => {
expect(wrapper.findAll(GlLabel).length).toBe(1);
expect(wrapper.text()).not.toContain('closed');
done();
})
.catch(done.fail);
});
});
describe('blocked', () => {
beforeEach((done) => {
wrapper.setProps({
issue: {
...wrapper.props('issue'),
blocked: true,
},
});
wrapper.vm.$nextTick(done);
});
it('renders blocked icon if issue is blocked', () => {
expect(wrapper.find('.issue-blocked-icon').exists()).toBe(true);
});
});
});

View File

@ -1,263 +0,0 @@
import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import axios from 'axios';
import AxiosMockAdapter from 'axios-mock-adapter';
import ProjectSelect from '~/boards/components/project_select_deprecated.vue';
import { ListType } from '~/boards/constants';
import eventHub from '~/boards/eventhub';
import createFlash from '~/flash';
import httpStatus from '~/lib/utils/http_status';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
import { listObj, mockRawGroupProjects } from './mock_data';
jest.mock('~/boards/eventhub');
jest.mock('~/flash');
const dummyGon = {
api_version: 'v4',
relative_url_root: '/gitlab',
};
const mockGroupId = 1;
const mockProjectsList1 = mockRawGroupProjects.slice(0, 1);
const mockProjectsList2 = mockRawGroupProjects.slice(1);
const mockDefaultFetchOptions = {
with_issues_enabled: true,
with_shared: false,
include_subgroups: true,
order_by: 'similarity',
archived: false,
};
const itemsPerPage = 20;
describe('ProjectSelect component', () => {
let wrapper;
let axiosMock;
const findLabel = () => wrapper.find("[data-testid='header-label']");
const findGlDropdown = () => wrapper.find(GlDropdown);
const findGlDropdownLoadingIcon = () =>
findGlDropdown().find('button:first-child').find(GlLoadingIcon);
const findGlSearchBoxByType = () => wrapper.find(GlSearchBoxByType);
const findGlDropdownItems = () => wrapper.findAll(GlDropdownItem);
const findFirstGlDropdownItem = () => findGlDropdownItems().at(0);
const findInMenuLoadingIcon = () => wrapper.find("[data-testid='dropdown-text-loading-icon']");
const findEmptySearchMessage = () => wrapper.find("[data-testid='empty-result-message']");
const mockGetRequest = (data = [], statusCode = httpStatus.OK) => {
axiosMock
.onGet(`/gitlab/api/v4/groups/${mockGroupId}/projects.json`)
.replyOnce(statusCode, data);
};
const searchForProject = async (keyword, waitForAll = true) => {
findGlSearchBoxByType().vm.$emit('input', keyword);
if (waitForAll) {
await axios.waitForAll();
}
};
const createWrapper = async ({ list = listObj } = {}, waitForAll = true) => {
wrapper = mount(ProjectSelect, {
propsData: {
list,
},
provide: {
groupId: 1,
},
});
if (waitForAll) {
await axios.waitForAll();
}
};
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
window.gon = dummyGon;
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
axiosMock.restore();
jest.clearAllMocks();
});
it('displays a header title', async () => {
createWrapper({});
expect(findLabel().text()).toBe('Projects');
});
it('renders a default dropdown text', async () => {
createWrapper({});
expect(findGlDropdown().exists()).toBe(true);
expect(findGlDropdown().text()).toContain('Select a project');
});
describe('when mounted', () => {
it('displays a loading icon while projects are being fetched', async () => {
mockGetRequest([]);
createWrapper({}, false);
expect(findGlDropdownLoadingIcon().exists()).toBe(true);
await axios.waitForAll();
expect(axiosMock.history.get[0].params).toMatchObject({ search: '' });
expect(axiosMock.history.get[0].url).toBe(
`/gitlab/api/v4/groups/${mockGroupId}/projects.json`,
);
expect(findGlDropdownLoadingIcon().exists()).toBe(false);
});
});
describe('when dropdown menu is open', () => {
describe('by default', () => {
beforeEach(async () => {
mockGetRequest(mockProjectsList1);
await createWrapper();
});
it('shows GlSearchBoxByType with default attributes', () => {
expect(findGlSearchBoxByType().exists()).toBe(true);
expect(findGlSearchBoxByType().vm.$attrs).toMatchObject({
placeholder: 'Search projects',
debounce: '250',
});
});
it("displays the fetched project's name", () => {
expect(findFirstGlDropdownItem().exists()).toBe(true);
expect(findFirstGlDropdownItem().text()).toContain(mockProjectsList1[0].name);
});
it("doesn't render loading icon in the menu", () => {
expect(findInMenuLoadingIcon().isVisible()).toBe(false);
});
it('renders empty search result message', async () => {
await createWrapper();
expect(findEmptySearchMessage().exists()).toBe(true);
});
});
describe('when a project is selected', () => {
beforeEach(async () => {
mockGetRequest(mockProjectsList1);
await createWrapper();
await findFirstGlDropdownItem().find('button').trigger('click');
});
it('emits setSelectedProject with correct project metadata', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('setSelectedProject', {
id: mockProjectsList1[0].id,
path: mockProjectsList1[0].path_with_namespace,
name: mockProjectsList1[0].name,
namespacedName: mockProjectsList1[0].name_with_namespace,
});
});
it('renders the name of the selected project', () => {
expect(findGlDropdown().find('.gl-new-dropdown-button-text').text()).toBe(
mockProjectsList1[0].name,
);
});
});
describe('when user searches for a project', () => {
beforeEach(async () => {
mockGetRequest(mockProjectsList1);
await createWrapper();
});
it('calls API with correct parameters with default fetch options', async () => {
await searchForProject('foobar');
const expectedApiParams = {
search: 'foobar',
per_page: itemsPerPage,
...mockDefaultFetchOptions,
};
expect(axiosMock.history.get[1].params).toMatchObject(expectedApiParams);
expect(axiosMock.history.get[1].url).toBe(
`/gitlab/api/v4/groups/${mockGroupId}/projects.json`,
);
});
describe("when list type is defined and isn't backlog", () => {
it('calls API with an additional fetch option (min_access_level)', async () => {
axiosMock.reset();
await createWrapper({ list: { ...listObj, type: ListType.label } });
await searchForProject('foobar');
const expectedApiParams = {
search: 'foobar',
per_page: itemsPerPage,
...mockDefaultFetchOptions,
min_access_level: featureAccessLevel.EVERYONE,
};
expect(axiosMock.history.get[1].params).toMatchObject(expectedApiParams);
expect(axiosMock.history.get[1].url).toBe(
`/gitlab/api/v4/groups/${mockGroupId}/projects.json`,
);
});
});
it('displays and hides gl-loading-icon while and after fetching data', async () => {
await searchForProject('some keyword', false);
await wrapper.vm.$nextTick();
expect(findInMenuLoadingIcon().isVisible()).toBe(true);
await axios.waitForAll();
expect(findInMenuLoadingIcon().isVisible()).toBe(false);
});
it('flashes an error message when fetching fails', async () => {
mockGetRequest([], httpStatus.INTERNAL_SERVER_ERROR);
await searchForProject('foobar');
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith({
message: 'Something went wrong while fetching projects',
});
});
describe('with non-empty search result', () => {
beforeEach(async () => {
mockGetRequest(mockProjectsList2);
await searchForProject('foobar');
});
it('displays the retrieved list of projects', async () => {
expect(findFirstGlDropdownItem().text()).toContain(mockProjectsList2[0].name);
});
it('does not render empty search result message', async () => {
expect(findEmptySearchMessage().exists()).toBe(false);
});
});
});
});
});

View File

@ -0,0 +1,166 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'validate database config' do
include RakeHelpers
include StubENV
let(:rails_configuration) { Rails::Application::Configuration.new(Rails.root) }
let(:ar_configurations) { ActiveRecord::DatabaseConfigurations.new(rails_configuration.database_configuration) }
subject do
load Rails.root.join('config/initializers/validate_database_config.rb')
end
before do
# The `AS::ConfigurationFile` calls `read` in `def initialize`
# thus we cannot use `expect_next_instance_of`
# rubocop:disable RSpec/AnyInstanceOf
expect_any_instance_of(ActiveSupport::ConfigurationFile)
.to receive(:read).with(Rails.root.join('config/database.yml')).and_return(database_yml)
# rubocop:enable RSpec/AnyInstanceOf
allow(Rails.application).to receive(:config).and_return(rails_configuration)
allow(ActiveRecord::Base).to receive(:configurations).and_return(ar_configurations)
end
shared_examples 'with SKIP_DATABASE_CONFIG_VALIDATION=true' do
before do
stub_env('SKIP_DATABASE_CONFIG_VALIDATION', 'true')
end
it 'does not raise exception' do
expect { subject }.not_to raise_error
end
end
context 'when config/database.yml is valid' do
context 'uses legacy syntax' do
let(:database_yml) do
<<-EOS
production:
adapter: postgresql
encoding: unicode
database: gitlabhq_production
username: git
password: "secure password"
host: localhost
EOS
end
it 'validates configuration with a warning' do
expect(main_object).to receive(:warn).with /uses a deprecated syntax for/
expect { subject }.not_to raise_error
end
it_behaves_like 'with SKIP_DATABASE_CONFIG_VALIDATION=true'
end
context 'uses new syntax' do
let(:database_yml) do
<<-EOS
production:
main:
adapter: postgresql
encoding: unicode
database: gitlabhq_production
username: git
password: "secure password"
host: localhost
EOS
end
it 'validates configuration without errors and warnings' do
expect(main_object).not_to receive(:warn)
expect { subject }.not_to raise_error
end
end
end
context 'when config/database.yml is invalid' do
context 'uses unknown connection name' do
let(:database_yml) do
<<-EOS
production:
main:
adapter: postgresql
encoding: unicode
database: gitlabhq_production
username: git
password: "secure password"
host: localhost
another:
adapter: postgresql
encoding: unicode
database: gitlabhq_production
username: git
password: "secure password"
host: localhost
EOS
end
it 'raises exception' do
expect { subject }.to raise_error /This installation of GitLab uses unsupported database names/
end
it_behaves_like 'with SKIP_DATABASE_CONFIG_VALIDATION=true'
end
context 'uses replica configuration' do
let(:database_yml) do
<<-EOS
production:
main:
adapter: postgresql
encoding: unicode
database: gitlabhq_production
username: git
password: "secure password"
host: localhost
replica: true
EOS
end
it 'raises exception' do
expect { subject }.to raise_error /with 'replica: true' parameter in/
end
it_behaves_like 'with SKIP_DATABASE_CONFIG_VALIDATION=true'
end
context 'main is not a first entry' do
let(:database_yml) do
<<-EOS
production:
ci:
adapter: postgresql
encoding: unicode
database: gitlabhq_production_ci
username: git
password: "secure password"
host: localhost
replica: true
main:
adapter: postgresql
encoding: unicode
database: gitlabhq_production
username: git
password: "secure password"
host: localhost
replica: true
EOS
end
it 'raises exception' do
expect { subject }.to raise_error /The `main:` database needs to be defined as a first configuration item/
end
it_behaves_like 'with SKIP_DATABASE_CONFIG_VALIDATION=true'
end
end
end

View File

@ -1,153 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Backup::GitalyRpcBackup do
let(:progress) { spy(:stdout) }
subject { described_class.new(progress) }
after do
# make sure we do not leave behind any backup files
FileUtils.rm_rf(File.join(Gitlab.config.backup.path, 'repositories'))
end
context 'unknown' do
it 'fails to start unknown' do
expect { subject.start(:unknown) }.to raise_error(::Backup::Error, 'unknown backup type: unknown')
end
end
context 'create' do
RSpec.shared_examples 'creates a repository backup' do
it 'creates repository bundles', :aggregate_failures do
# Add data to the wiki, design repositories, and snippets, so they will be included in the dump.
create(:wiki_page, container: project)
create(:design, :with_file, issue: create(:issue, project: project))
project_snippet = create(:project_snippet, :repository, project: project)
personal_snippet = create(:personal_snippet, :repository, author: project.owner)
subject.start(:create)
subject.enqueue(project, Gitlab::GlRepository::PROJECT)
subject.enqueue(project, Gitlab::GlRepository::WIKI)
subject.enqueue(project, Gitlab::GlRepository::DESIGN)
subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET)
subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET)
subject.wait
expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project.disk_path + '.bundle'))
expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project.disk_path + '.wiki.bundle'))
expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project.disk_path + '.design.bundle'))
expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', personal_snippet.disk_path + '.bundle'))
expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project_snippet.disk_path + '.bundle'))
end
context 'failure' do
before do
allow_next_instance_of(Repository) do |repository|
allow(repository).to receive(:bundle_to_disk) { raise 'Fail in tests' }
end
end
it 'logs an appropriate message', :aggregate_failures do
subject.start(:create)
subject.enqueue(project, Gitlab::GlRepository::PROJECT)
subject.wait
expect(progress).to have_received(:puts).with("[Failed] backing up #{project.full_path} (#{project.disk_path})")
expect(progress).to have_received(:puts).with("Error Fail in tests")
end
end
end
context 'hashed storage' do
let_it_be(:project) { create(:project, :repository) }
it_behaves_like 'creates a repository backup'
end
context 'legacy storage' do
let_it_be(:project) { create(:project, :repository, :legacy_storage) }
it_behaves_like 'creates a repository backup'
end
end
context 'restore' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:personal_snippet) { create(:personal_snippet, author: project.owner) }
let_it_be(:project_snippet) { create(:project_snippet, project: project, author: project.owner) }
def copy_bundle_to_backup_path(bundle_name, destination)
FileUtils.mkdir_p(File.join(Gitlab.config.backup.path, 'repositories', File.dirname(destination)))
FileUtils.cp(Rails.root.join('spec/fixtures/lib/backup', bundle_name), File.join(Gitlab.config.backup.path, 'repositories', destination))
end
it 'restores from repository bundles', :aggregate_failures do
copy_bundle_to_backup_path('project_repo.bundle', project.disk_path + '.bundle')
copy_bundle_to_backup_path('wiki_repo.bundle', project.disk_path + '.wiki.bundle')
copy_bundle_to_backup_path('design_repo.bundle', project.disk_path + '.design.bundle')
copy_bundle_to_backup_path('personal_snippet_repo.bundle', personal_snippet.disk_path + '.bundle')
copy_bundle_to_backup_path('project_snippet_repo.bundle', project_snippet.disk_path + '.bundle')
subject.start(:restore)
subject.enqueue(project, Gitlab::GlRepository::PROJECT)
subject.enqueue(project, Gitlab::GlRepository::WIKI)
subject.enqueue(project, Gitlab::GlRepository::DESIGN)
subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET)
subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET)
subject.wait
collect_commit_shas = -> (repo) { repo.commits('master', limit: 10).map(&:sha) }
expect(collect_commit_shas.call(project.repository)).to eq(['393a7d860a5a4c3cc736d7eb00604e3472bb95ec'])
expect(collect_commit_shas.call(project.wiki.repository)).to eq(['c74b9948d0088d703ee1fafeddd9ed9add2901ea'])
expect(collect_commit_shas.call(project.design_repository)).to eq(['c3cd4d7bd73a51a0f22045c3a4c871c435dc959d'])
expect(collect_commit_shas.call(personal_snippet.repository)).to eq(['3b3c067a3bc1d1b695b51e2be30c0f8cf698a06e'])
expect(collect_commit_shas.call(project_snippet.repository)).to eq(['6e44ba56a4748be361a841e759c20e421a1651a1'])
end
it 'cleans existing repositories', :aggregate_failures do
expect_next_instance_of(DesignManagement::Repository) do |repository|
expect(repository).to receive(:remove)
end
# 4 times = project repo + wiki repo + project_snippet repo + personal_snippet repo
expect(Repository).to receive(:new).exactly(4).times.and_wrap_original do |method, *original_args|
full_path, container, kwargs = original_args
repository = method.call(full_path, container, **kwargs)
expect(repository).to receive(:remove)
repository
end
subject.start(:restore)
subject.enqueue(project, Gitlab::GlRepository::PROJECT)
subject.enqueue(project, Gitlab::GlRepository::WIKI)
subject.enqueue(project, Gitlab::GlRepository::DESIGN)
subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET)
subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET)
subject.wait
end
context 'failure' do
before do
allow_next_instance_of(Repository) do |repository|
allow(repository).to receive(:create_repository) { raise 'Fail in tests' }
allow(repository).to receive(:create_from_bundle) { raise 'Fail in tests' }
end
end
it 'logs an appropriate message', :aggregate_failures do
subject.start(:restore)
subject.enqueue(project, Gitlab::GlRepository::PROJECT)
subject.wait
expect(progress).to have_received(:puts).with("[Failed] restoring #{project.full_path} (#{project.disk_path})")
expect(progress).to have_received(:puts).with("Error Fail in tests")
end
end
end
end

View File

@ -4,8 +4,7 @@ require 'spec_helper'
RSpec.describe Backup::Repositories do
let(:progress) { spy(:stdout) }
let(:parallel_enqueue) { true }
let(:strategy) { spy(:strategy, parallel_enqueue?: parallel_enqueue) }
let(:strategy) { spy(:strategy) }
subject { described_class.new(progress, strategy: strategy) }
@ -17,7 +16,7 @@ RSpec.describe Backup::Repositories do
project_snippet = create(:project_snippet, :repository, project: project)
personal_snippet = create(:personal_snippet, :repository, author: project.owner)
subject.dump(max_concurrency: 1, max_storage_concurrency: 1)
subject.dump
expect(strategy).to have_received(:start).with(:create)
expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::PROJECT)
@ -41,132 +40,30 @@ RSpec.describe Backup::Repositories do
it_behaves_like 'creates repository bundles'
end
context 'no concurrency' do
it 'creates the expected number of threads' do
expect(Thread).not_to receive(:new)
context 'command failure' do
it 'enqueue_project raises an error' do
allow(strategy).to receive(:enqueue).with(anything, Gitlab::GlRepository::PROJECT).and_raise(IOError)
expect(strategy).to receive(:start).with(:create)
projects.each do |project|
expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT)
end
expect(strategy).to receive(:wait)
subject.dump(max_concurrency: 1, max_storage_concurrency: 1)
expect { subject.dump }.to raise_error(IOError)
end
describe 'command failure' do
it 'enqueue_project raises an error' do
allow(strategy).to receive(:enqueue).with(anything, Gitlab::GlRepository::PROJECT).and_raise(IOError)
it 'project query raises an error' do
allow(Project).to receive_message_chain(:includes, :find_each).and_raise(ActiveRecord::StatementTimeout)
expect { subject.dump(max_concurrency: 1, max_storage_concurrency: 1) }.to raise_error(IOError)
end
it 'project query raises an error' do
allow(Project).to receive_message_chain(:includes, :find_each).and_raise(ActiveRecord::StatementTimeout)
expect { subject.dump(max_concurrency: 1, max_storage_concurrency: 1) }.to raise_error(ActiveRecord::StatementTimeout)
end
end
it 'avoids N+1 database queries' do
control_count = ActiveRecord::QueryRecorder.new do
subject.dump(max_concurrency: 1, max_storage_concurrency: 1)
end.count
create_list(:project, 2, :repository)
expect do
subject.dump(max_concurrency: 1, max_storage_concurrency: 1)
end.not_to exceed_query_limit(control_count)
expect { subject.dump }.to raise_error(ActiveRecord::StatementTimeout)
end
end
context 'concurrency with a strategy without parallel enqueueing support' do
let(:parallel_enqueue) { false }
it 'avoids N+1 database queries' do
control_count = ActiveRecord::QueryRecorder.new do
subject.dump
end.count
it 'enqueues all projects sequentially' do
expect(Thread).not_to receive(:new)
create_list(:project, 2, :repository)
expect(strategy).to receive(:start).with(:create)
projects.each do |project|
expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT)
end
expect(strategy).to receive(:wait)
subject.dump(max_concurrency: 2, max_storage_concurrency: 2)
end
end
[4, 10].each do |max_storage_concurrency|
context "max_storage_concurrency #{max_storage_concurrency}", quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/241701' do
let(:storage_keys) { %w[default test_second_storage] }
before do
allow(Gitlab.config.repositories.storages).to receive(:keys).and_return(storage_keys)
end
it 'creates the expected number of threads' do
expect(Thread).to receive(:new)
.exactly(storage_keys.length * (max_storage_concurrency + 1)).times
.and_call_original
expect(strategy).to receive(:start).with(:create)
projects.each do |project|
expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT)
end
expect(strategy).to receive(:wait)
subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency)
end
it 'creates the expected number of threads with extra max concurrency' do
expect(Thread).to receive(:new)
.exactly(storage_keys.length * (max_storage_concurrency + 1)).times
.and_call_original
expect(strategy).to receive(:start).with(:create)
projects.each do |project|
expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT)
end
expect(strategy).to receive(:wait)
subject.dump(max_concurrency: 3, max_storage_concurrency: max_storage_concurrency)
end
describe 'command failure' do
it 'enqueue_project raises an error' do
allow(strategy).to receive(:enqueue).and_raise(IOError)
expect { subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) }.to raise_error(IOError)
end
it 'project query raises an error' do
allow(Project).to receive_message_chain(:for_repository_storage, :includes, :find_each).and_raise(ActiveRecord::StatementTimeout)
expect { subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) }.to raise_error(ActiveRecord::StatementTimeout)
end
context 'misconfigured storages' do
let(:storage_keys) { %w[test_second_storage] }
it 'raises an error' do
expect { subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) }.to raise_error(Backup::Error, 'repositories.storages in gitlab.yml is misconfigured')
end
end
end
it 'avoids N+1 database queries' do
control_count = ActiveRecord::QueryRecorder.new do
subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency)
end.count
create_list(:project, 2, :repository)
expect do
subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency)
end.not_to exceed_query_limit(control_count)
end
end
expect do
subject.dump
end.not_to exceed_query_limit(control_count)
end
end

View File

@ -0,0 +1,123 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Patch::LegacyDatabaseConfig do
it 'module is included' do
expect(Rails::Application::Configuration).to include(described_class)
end
describe 'config/database.yml' do
let(:configuration) { Rails::Application::Configuration.new(Rails.root) }
before do
# The `AS::ConfigurationFile` calls `read` in `def initialize`
# thus we cannot use `expect_next_instance_of`
# rubocop:disable RSpec/AnyInstanceOf
expect_any_instance_of(ActiveSupport::ConfigurationFile)
.to receive(:read).with(Rails.root.join('config/database.yml')).and_return(database_yml)
# rubocop:enable RSpec/AnyInstanceOf
end
shared_examples 'hash containing main: connection name' do
it 'returns a hash containing only main:' do
database_configuration = configuration.database_configuration
expect(database_configuration).to match(
"production" => { "main" => a_hash_including("adapter") },
"development" => { "main" => a_hash_including("adapter" => "postgresql") },
"test" => { "main" => a_hash_including("adapter" => "postgresql") }
)
end
end
context 'when a new syntax is used' do
let(:database_yml) do
<<-EOS
production:
main:
adapter: postgresql
encoding: unicode
database: gitlabhq_production
username: git
password: "secure password"
host: localhost
development:
main:
adapter: postgresql
encoding: unicode
database: gitlabhq_development
username: postgres
password: "secure password"
host: localhost
variables:
statement_timeout: 15s
test: &test
main:
adapter: postgresql
encoding: unicode
database: gitlabhq_test
username: postgres
password:
host: localhost
prepared_statements: false
variables:
statement_timeout: 15s
EOS
end
include_examples 'hash containing main: connection name'
it 'configuration is not legacy one' do
configuration.database_configuration
expect(configuration.uses_legacy_database_config).to eq(false)
end
end
context 'when a legacy syntax is used' do
let(:database_yml) do
<<-EOS
production:
adapter: postgresql
encoding: unicode
database: gitlabhq_production
username: git
password: "secure password"
host: localhost
development:
adapter: postgresql
encoding: unicode
database: gitlabhq_development
username: postgres
password: "secure password"
host: localhost
variables:
statement_timeout: 15s
test: &test
adapter: postgresql
encoding: unicode
database: gitlabhq_test
username: postgres
password:
host: localhost
prepared_statements: false
variables:
statement_timeout: 15s
EOS
end
include_examples 'hash containing main: connection name'
it 'configuration is legacy' do
configuration.database_configuration
expect(configuration.uses_legacy_database_config).to eq(true)
end
end
end
end

View File

@ -0,0 +1,53 @@
# frozen_string_literal: true
require 'spec_helper'
require_migration!
RSpec.describe RemoveDuplicateDastSiteTokens do
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
let(:dast_site_tokens) { table(:dast_site_tokens) }
let!(:namespace) { namespaces.create!(id: 1, name: 'group', path: 'group') }
let!(:project1) { projects.create!(id: 1, namespace_id: namespace.id, path: 'project1') }
# create non duplicate dast site token
let!(:dast_site_token1) { dast_site_tokens.create!(project_id: project1.id, url: 'https://gitlab.com', token: SecureRandom.uuid) }
context 'when duplicate dast site tokens exists' do
# create duplicate dast site token
let_it_be(:duplicate_url) { 'https://about.gitlab.com' }
let!(:project2) { projects.create!(id: 2, namespace_id: namespace.id, path: 'project2') }
let!(:dast_site_token2) { dast_site_tokens.create!(project_id: project2.id, url: duplicate_url, token: SecureRandom.uuid) }
let!(:dast_site_token3) { dast_site_tokens.create!(project_id: project2.id, url: 'https://temp_url.com', token: SecureRandom.uuid) }
let!(:dast_site_token4) { dast_site_tokens.create!(project_id: project2.id, url: 'https://other_temp_url.com', token: SecureRandom.uuid) }
before 'update URL to bypass uniqueness validation' do
dast_site_tokens.where(project_id: 2).update_all(url: duplicate_url)
end
describe 'migration up' do
it 'does remove duplicated dast site tokens' do
expect(dast_site_tokens.count).to eq(4)
expect(dast_site_tokens.where(project_id: 2, url: duplicate_url).size).to eq(3)
migrate!
expect(dast_site_tokens.count).to eq(2)
expect(dast_site_tokens.where(project_id: 2, url: duplicate_url).size).to eq(1)
end
end
end
context 'when duplicate dast site tokens does not exists' do
before do
dast_site_tokens.create!(project_id: 1, url: 'https://about.gitlab.com/handbook', token: SecureRandom.uuid)
end
describe 'migration up' do
it 'does remove duplicated dast site tokens' do
expect { migrate! }.not_to change(dast_site_tokens, :count)
end
end
end
end

View File

@ -383,30 +383,10 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do
create(:project, :repository)
end
it 'has defaults' do
expect_next_instance_of(::Backup::Repositories) do |instance|
expect(instance).to receive(:dump)
.with(max_concurrency: 1, max_storage_concurrency: 1)
.and_call_original
end
expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout_from_any_process
end
it 'passes through concurrency environment variables' do
# The way concurrency is handled will change with the `gitaly_backup`
# feature flag. For now we need to check that both ways continue to
# work. This will be cleaned up in the rollout issue.
# See https://gitlab.com/gitlab-org/gitlab/-/issues/333034
stub_env('GITLAB_BACKUP_MAX_CONCURRENCY', 5)
stub_env('GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY', 2)
expect_next_instance_of(::Backup::Repositories) do |instance|
expect(instance).to receive(:dump)
.with(max_concurrency: 5, max_storage_concurrency: 2)
.and_call_original
end
expect(::Backup::GitalyBackup).to receive(:new).with(anything, parallel: 5, parallel_storage: 2).and_call_original
expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout_from_any_process

View File

@ -3898,10 +3898,10 @@ core-js-pure@^3.0.0:
resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.6.5.tgz#c79e75f5e38dbc85a662d91eea52b8256d53b813"
integrity sha512-lacdXOimsiD0QyNf9BC/mxivNJ/ybBGJXQFKzRekp1WTHoVUWsUHEn+2T8GJAzzIhyOuXA+gOxCVN3l+5PLPUA==
core-js@^3.1.3, core-js@^3.16.4:
version "3.16.4"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.16.4.tgz#0fb1029a554fc2688c0963d7c900e188188a78e0"
integrity sha512-Tq4GVE6XCjE+hcyW6hPy0ofN3hwtLudz5ZRdrlCnsnD/xkm/PWQRudzYHiKgZKUcefV6Q57fhDHjZHJP5dpfSg==
core-js@^3.1.3, core-js@^3.17.1:
version "3.17.1"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.17.1.tgz#b39e086f413789cf2ca4680c4ecd1b36a50ba277"
integrity sha512-C8i/FNpVN2Ti89QIJcFn9ZQmnM+HaAQr2OpE+ja3TRM9Q34FigsGlAVuwPGkIgydSVClo/1l1D1grP8LVt9IYA==
core-js@~2.3.0:
version "2.3.0"