Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-09-24 12:09:37 +00:00
parent 7a8d983c19
commit 6f15c2c272
122 changed files with 1822 additions and 1248 deletions

View File

@ -64,6 +64,7 @@ const Api = {
issuePath: '/api/:version/projects/:id/issues/:issue_iid',
tagsPath: '/api/:version/projects/:id/repository/tags',
freezePeriodsPath: '/api/:version/projects/:id/freeze_periods',
usageDataIncrementUniqueUsersPath: '/api/:version/usage_data/increment_unique_users',
group(groupId, callback = () => {}) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
@ -686,6 +687,19 @@ const Api = {
return axios.post(url, freezePeriod);
},
trackRedisHllUserEvent(event) {
if (!gon.features?.usageDataApi) {
return null;
}
const url = Api.buildUrl(this.usageDataIncrementUniqueUsersPath);
const headers = {
'Content-Type': 'application/json',
};
return axios.post(url, { event }, { headers });
},
buildUrl(url) {
return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version));
},

View File

@ -2,32 +2,36 @@
import { escape } from 'lodash';
import { mapActions, mapGetters } from 'vuex';
import {
GlDeprecatedButton,
GlTooltipDirective,
GlSafeHtmlDirective,
GlLoadingIcon,
GlIcon,
GlButton,
GlButtonGroup,
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlDropdownDivider,
} from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import { truncateSha } from '~/lib/utils/text_utility';
import { __, s__, sprintf } from '~/locale';
import { diffViewerModes } from '~/ide/constants';
import EditButton from './edit_button.vue';
import DiffStats from './diff_stats.vue';
import { scrollToElement } from '~/lib/utils/common_utils';
export default {
components: {
GlLoadingIcon,
GlDeprecatedButton,
ClipboardButton,
EditButton,
GlIcon,
FileIcon,
DiffStats,
GlButton,
GlButtonGroup,
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlDropdownDivider,
},
directives: {
GlTooltip: GlTooltipDirective,
@ -70,7 +74,7 @@ export default {
},
data() {
return {
hasDropdownOpen: false,
moreActionsShown: false,
};
},
computed: {
@ -155,6 +159,13 @@ export default {
}
return s__('MRDiff|Show full file');
},
showEditButton() {
return (
this.diffFile.blob?.readable_text &&
!this.diffFile.deleted_file &&
(this.diffFile.edit_path || this.diffFile.ide_edit_path)
);
},
},
methods: {
...mapActions('diffs', [
@ -166,8 +177,11 @@ export default {
handleToggleFile() {
this.$emit('toggleFile');
},
showForkMessage() {
this.$emit('showForkMessage');
showForkMessage(e) {
if (this.canCurrentUserFork && !this.diffFile.can_modify_blob) {
e.preventDefault();
this.$emit('showForkMessage');
}
},
handleFileNameClick(e) {
const isLinkToOtherPage =
@ -183,8 +197,8 @@ export default {
}
}
},
setDropdownOpen(val) {
this.hasDropdownOpen = val;
setMoreActionsShown(val) {
this.moreActionsShown = val;
},
},
};
@ -193,8 +207,8 @@ export default {
<template>
<div
ref="header"
:class="{ 'gl-z-dropdown-menu!': moreActionsShown }"
class="js-file-title file-title file-title-flex-parent"
:class="{ 'gl-z-dropdown-menu!': hasDropdownOpen }"
@click.self="handleToggleFile"
>
<div class="file-header-content">
@ -255,96 +269,95 @@ export default {
<div
v-if="!diffFile.submodule && addMergeRequestButtons"
class="file-actions d-none d-sm-flex align-items-center flex-wrap"
class="file-actions d-flex align-items-center flex-wrap"
>
<diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" />
<div class="btn-group" role="group">
<template v-if="diffFile.blob && diffFile.blob.readable_text">
<span v-gl-tooltip.hover :title="s__('MergeRequests|Toggle comments for this file')">
<gl-deprecated-button
ref="toggleDiscussionsButton"
:disabled="!diffHasDiscussions(diffFile)"
:class="{ active: diffHasExpandedDiscussions(diffFile) }"
class="js-btn-vue-toggle-comments btn"
data-qa-selector="toggle_comments_button"
data-track-event="click_toggle_comments_button"
data-track-label="diff_toggle_comments_button"
data-track-property="diff_toggle_comments"
type="button"
@click="toggleFileDiscussionWrappers(diffFile)"
>
<gl-icon name="comment" />
</gl-deprecated-button>
</span>
<edit-button
v-if="!diffFile.deleted_file"
:can-current-user-fork="canCurrentUserFork"
:edit-path="diffFile.edit_path"
:ide-edit-path="diffFile.ide_edit_path"
:can-modify-blob="diffFile.can_modify_blob"
data-track-event="click_toggle_edit_button"
data-track-label="diff_toggle_edit_button"
data-track-property="diff_toggle_edit"
@showForkMessage="showForkMessage"
@open="setDropdownOpen(true)"
@close="setDropdownOpen(false)"
/>
</template>
<a
v-if="diffFile.replaced_view_path"
ref="replacedFileButton"
v-safe-html="viewReplacedFileButtonText"
:href="diffFile.replaced_view_path"
class="btn view-file"
>
</a>
<gl-deprecated-button
v-if="!diffFile.is_fully_expanded"
ref="expandDiffToFullFileButton"
v-gl-tooltip.hover
:title="expandDiffToFullFileTitle"
class="expand-file"
data-track-event="click_toggle_view_full_button"
data-track-label="diff_toggle_view_full_button"
data-track-property="diff_toggle_view_full"
@click="toggleFullDiff(diffFile.file_path)"
>
<gl-loading-icon v-if="diffFile.isLoadingFullFile" color="dark" inline />
<gl-icon v-else-if="diffFile.isShowingFullFile" name="doc-changes" />
<gl-icon v-else name="doc-expand" />
</gl-deprecated-button>
<gl-deprecated-button
ref="viewButton"
v-gl-tooltip.hover
:href="diffFile.view_path"
target="_blank"
class="view-file"
data-track-event="click_toggle_view_sha_button"
data-track-label="diff_toggle_view_sha_button"
data-track-property="diff_toggle_view_sha"
:title="viewFileButtonText"
>
<gl-icon name="doc-text" />
</gl-deprecated-button>
<a
<gl-button-group class="gl-pt-0!">
<gl-button
v-if="diffFile.external_url"
ref="externalLink"
v-gl-tooltip.hover
:href="diffFile.external_url"
:title="`View on ${diffFile.formatted_external_url}`"
target="_blank"
rel="noopener noreferrer"
data-track-event="click_toggle_external_button"
data-track-label="diff_toggle_external_button"
data-track-property="diff_toggle_external"
class="btn btn-file-option"
icon="external-link"
/>
<gl-dropdown
v-gl-tooltip.hover.focus="__('More actions')"
right
toggle-class="btn-icon js-diff-more-actions"
class="gl-pt-0!"
@show="setMoreActionsShown(true)"
@hidden="setMoreActionsShown(false)"
>
<gl-icon name="external-link" />
</a>
</div>
<template #button-content>
<gl-icon name="ellipsis_v" class="mr-0" />
<span class="sr-only">{{ __('More actions') }}</span>
</template>
<gl-dropdown-section-header>
{{ __('More actions') }}
</gl-dropdown-section-header>
<gl-dropdown-item
v-if="diffFile.replaced_view_path"
ref="replacedFileButton"
v-safe-html="viewReplacedFileButtonText"
:href="diffFile.replaced_view_path"
target="_blank"
/>
<gl-dropdown-item ref="viewButton" :href="diffFile.view_path" target="_blank">
{{ viewFileButtonText }}
</gl-dropdown-item>
<template v-if="showEditButton">
<gl-dropdown-item
v-if="diffFile.edit_path"
ref="editButton"
:href="diffFile.edit_path"
class="js-edit-blob"
@click="showForkMessage"
>
{{ __('Edit in single-file editor') }}
</gl-dropdown-item>
<gl-dropdown-item
v-if="diffFile.edit_path"
ref="ideEditButton"
:href="diffFile.ide_edit_path"
class="js-ide-edit-blob"
>
{{ __('Edit in Web IDE') }}
</gl-dropdown-item>
</template>
<template v-if="!diffFile.viewer.collapsed">
<gl-dropdown-divider
v-if="!diffFile.is_fully_expanded || diffHasDiscussions(diffFile)"
/>
<gl-dropdown-item
v-if="diffHasDiscussions(diffFile)"
ref="toggleDiscussionsButton"
data-qa-selector="toggle_comments_button"
@click="toggleFileDiscussionWrappers(diffFile)"
>
<template v-if="diffHasExpandedDiscussions(diffFile)">
{{ __('Hide comments on this file') }}
</template>
<template v-else>
{{ __('Show comments on this file') }}
</template>
</gl-dropdown-item>
<gl-dropdown-item
v-if="!diffFile.is_fully_expanded"
ref="expandDiffToFullFileButton"
@click="toggleFullDiff(diffFile.file_path)"
>
{{ expandDiffToFullFileTitle }}
</gl-dropdown-item>
</template>
</gl-dropdown>
</gl-button-group>
</div>
<div

View File

@ -42,7 +42,7 @@ export default {
class="diff-stats"
:class="{
'is-compare-versions-header d-none d-lg-inline-flex': isCompareVersionsHeader,
'd-inline-flex': !isCompareVersionsHeader,
'd-none d-sm-inline-flex': !isCompareVersionsHeader,
}"
>
<div v-if="hasDiffFiles" class="diff-stats-group">

View File

@ -1,99 +0,0 @@
<script>
import { uniqueId } from 'lodash';
import {
GlTooltipDirective,
GlIcon,
GlDeprecatedDropdown as GlDropdown,
GlDeprecatedDropdownItem as GlDropdownItem,
} from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlDropdown,
GlDropdownItem,
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
editPath: {
type: String,
required: false,
default: '',
},
ideEditPath: {
type: String,
required: false,
default: '',
},
canCurrentUserFork: {
type: Boolean,
required: true,
},
canModifyBlob: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return { tooltipId: uniqueId('edit_button_tooltip_') };
},
computed: {
tooltipTitle() {
if (this.isDisabled) {
return __("Can't edit as source branch was deleted");
}
return __('Edit file in...');
},
isDisabled() {
return !this.editPath;
},
},
methods: {
handleShow(evt) {
// We must hide the tooltip because it is redundant and doesn't close itself
// when dropdown opens because we are still "focused".
this.$root.$emit('bv::hide::tooltip', this.tooltipId);
if (this.canCurrentUserFork && !this.canModifyBlob) {
evt.preventDefault();
this.$emit('showForkMessage');
} else {
this.$emit('open');
}
},
handleHide() {
this.$emit('close');
},
},
};
</script>
<template>
<div v-gl-tooltip.top="{ title: tooltipTitle, id: tooltipId }" class="gl-display-flex">
<gl-dropdown
toggle-class="rounded-0"
:disabled="isDisabled"
:class="{ 'cursor-not-allowed': isDisabled }"
right
data-testid="edit_file"
@show="handleShow"
@hide="handleHide"
>
<template #button-content>
<span class="gl-dropdown-toggle-text"><gl-icon name="pencil"/></span>
<gl-icon class="gl-dropdown-caret" name="chevron-down" aria-hidden="true" />
</template>
<gl-dropdown-item v-if="editPath" :href="editPath">{{
__('Edit in single-file editor')
}}</gl-dropdown-item>
<gl-dropdown-item v-if="ideEditPath" :href="ideEditPath">{{
__('Edit in Web IDE')
}}</gl-dropdown-item>
</gl-dropdown>
</div>
</template>

View File

@ -46,15 +46,24 @@ export const diffHasAllCollapsedDiscussions = (state, getters) => diff => {
* @param {Object} diff
* @returns {Boolean}
*/
export const diffHasExpandedDiscussions = (state, getters) => diff => {
const discussions = getters.getDiffFileDiscussions(diff);
export const diffHasExpandedDiscussions = state => diff => {
const lines = {
[INLINE_DIFF_VIEW_TYPE]: diff.highlighted_diff_lines || [],
[PARALLEL_DIFF_VIEW_TYPE]: (diff.parallel_diff_lines || []).reduce((acc, line) => {
if (line.left) {
acc.push(line.left);
}
return (
(discussions &&
discussions.length &&
discussions.find(discussion => discussion.expanded) !== undefined) ||
false
);
if (line.right) {
acc.push(line.right);
}
return acc;
}, []),
};
return lines[window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType]
.filter(l => l.discussions.length >= 1)
.some(l => l.discussionsExpanded);
};
/**
@ -62,8 +71,25 @@ export const diffHasExpandedDiscussions = (state, getters) => diff => {
* @param {Boolean} diff
* @returns {Boolean}
*/
export const diffHasDiscussions = (state, getters) => diff =>
getters.getDiffFileDiscussions(diff).length > 0;
export const diffHasDiscussions = state => diff => {
const lines = {
[INLINE_DIFF_VIEW_TYPE]: diff.highlighted_diff_lines || [],
[PARALLEL_DIFF_VIEW_TYPE]: (diff.parallel_diff_lines || []).reduce((acc, line) => {
if (line.left) {
acc.push(line.left);
}
if (line.right) {
acc.push(line.right);
}
return acc;
}, []),
};
return lines[window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType].some(
l => l.discussions.length >= 1,
);
};
/**
* Returns an array with the discussions of the given diff

View File

@ -1,11 +1,10 @@
<script>
import { GlLink, GlTooltipDirective, GlSprintf } from '@gitlab/ui';
import { GlLink, GlTooltipDirective } from '@gitlab/ui';
import { formatDate } from '~/lib/utils/datetime_utility';
export default {
components: {
GlLink,
GlSprintf,
},
directives: {
GlTooltip: GlTooltipDirective,
@ -26,16 +25,12 @@ export default {
<template>
<div
class="gl-border-solid gl-border-1 gl-border-gray-100 gl-p-5 gl-mb-3 gl-rounded-base gl-display-flex gl-justify-content-space-between"
class="gl-border-solid gl-border-1 gl-border-gray-100 gl-p-5 gl-mb-3 gl-rounded-base gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column"
>
<div class="gl-pr-3">
<span class="gl-font-weight-bold">{{ s__('HighlightBar|Original alert:') }}</span>
<gl-link v-gl-tooltip :title="alert.title" :href="alert.detailsUrl">
<gl-sprintf :message="__('Alert #%{alertId}')">
<template #alertId>
<span>{{ alert.iid }}</span>
</template>
</gl-sprintf>
#{{ alert.iid }}
</gl-link>
</div>

View File

@ -1,6 +1,7 @@
<script>
import { mapGetters, mapActions } from 'vuex';
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { GlTooltipDirective, GlIcon, GlButton, GlButtonGroup } from '@gitlab/ui';
import { __ } from '~/locale';
import discussionNavigation from '../mixins/discussion_navigation';
export default {
@ -9,6 +10,8 @@ export default {
},
components: {
GlIcon,
GlButton,
GlButtonGroup,
},
mixins: [discussionNavigation],
computed: {
@ -34,6 +37,12 @@ export default {
allExpanded() {
return this.toggeableDiscussions.every(discussion => discussion.expanded);
},
lineResolveClass() {
return this.allResolved ? 'line-resolve-btn is-active' : 'line-resolve-text';
},
toggleThreadsLabel() {
return this.allExpanded ? __('Collapse all threads') : __('Expand all threads');
},
},
methods: {
...mapActions(['setExpandDiscussions']),
@ -51,59 +60,49 @@ export default {
<div
v-if="resolvableDiscussionsCount > 0"
ref="discussionCounter"
class="line-resolve-all-container full-width-mobile"
class="line-resolve-all-container full-width-mobile gl-display-flex d-sm-flex"
>
<div class="full-width-mobile d-flex d-sm-flex">
<div class="line-resolve-all">
<span
:class="{ 'line-resolve-btn is-active': allResolved, 'line-resolve-text': !allResolved }"
>
<template v-if="allResolved">
<gl-icon name="check-circle-filled" />
{{ __('All threads resolved') }}
</template>
<template v-else>
{{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }}
</template>
</span>
</div>
<div
v-if="resolveAllDiscussionsIssuePath && !allResolved"
class="btn-group btn-group-sm"
role="group"
>
<a
v-gl-tooltip
:href="resolveAllDiscussionsIssuePath"
:title="s__('Resolve all threads in new issue')"
class="new-issue-for-discussion btn btn-default discussion-create-issue-btn"
>
<gl-icon name="issue-new" />
</a>
</div>
<div v-if="isLoggedIn && !allResolved" class="btn-group btn-group-sm" role="group">
<button
v-gl-tooltip
:title="__('Jump to next unresolved thread')"
class="btn btn-default discussion-next-btn"
data-track-event="click_button"
data-track-label="mr_next_unresolved_thread"
data-track-property="click_next_unresolved_thread_top"
@click="jumpToNextDiscussion"
>
<gl-icon name="comment-next" />
</button>
</div>
<div class="btn-group btn-group-sm" role="group">
<button
v-gl-tooltip
:title="__('Toggle all threads')"
class="btn btn-default toggle-all-discussions-btn"
@click="handleExpandDiscussions"
>
<gl-icon :name="allExpanded ? 'angle-up' : 'angle-down'" />
</button>
</div>
<div class="line-resolve-all">
<span :class="lineResolveClass">
<template v-if="allResolved">
<gl-icon name="check-circle-filled" />
{{ __('All threads resolved') }}
</template>
<template v-else>
{{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }}
</template>
</span>
</div>
<gl-button-group>
<gl-button
v-if="resolveAllDiscussionsIssuePath && !allResolved"
v-gl-tooltip
:href="resolveAllDiscussionsIssuePath"
:title="s__('Resolve all threads in new issue')"
:aria-label="s__('Resolve all threads in new issue')"
class="new-issue-for-discussion discussion-create-issue-btn"
icon="issue-new"
/>
<gl-button
v-if="isLoggedIn && !allResolved"
v-gl-tooltip
:title="__('Jump to next unresolved thread')"
:aria-label="__('Jump to next unresolved thread')"
class="discussion-next-btn"
data-track-event="click_button"
data-track-label="mr_next_unresolved_thread"
data-track-property="click_next_unresolved_thread_top"
icon="comment-next"
@click="jumpToNextDiscussion"
/>
<gl-button
v-gl-tooltip
:title="toggleThreadsLabel"
:aria-label="toggleThreadsLabel"
class="toggle-all-discussions-btn"
:icon="allExpanded ? 'angle-up' : 'angle-down'"
@click="handleExpandDiscussions"
/>
</gl-button-group>
</div>
</template>

View File

@ -160,7 +160,7 @@ export default {
});
},
displayMemberBadgeText() {
return sprintf(__('This user is a %{access} of the %{name} project.'), {
return sprintf(__('This user has the %{access} role in the %{name} project.'), {
access: this.accessLevel.toLowerCase(),
name: this.projectName,
});

View File

@ -151,7 +151,7 @@ export default {
this.newSnippet = false;
},
onSnippetFetch(snippetRes) {
if (snippetRes.data.snippets.edges.length === 0) {
if (snippetRes.data.snippets.nodes.length === 0) {
this.onNewSnippetFetched();
} else {
this.onExistingSnippetFetched();

View File

@ -23,6 +23,7 @@ export default {
return {
ids: this.snippet.id,
rich: this.activeViewerType === RICH_BLOB_VIEWER,
paths: [this.blob.path],
};
},
update(data) {
@ -79,8 +80,10 @@ export default {
},
onContentUpdate(data) {
const { path: blobPath } = this.blob;
const { blobs } = data.snippets.edges[0].node;
const updatedBlobData = blobs.find(blob => blob.path === blobPath);
const {
blobs: { nodes: dataBlobs },
} = data.snippets.nodes[0];
const updatedBlobData = dataBlobs.find(blob => blob.path === blobPath);
return updatedBlobData.richData || updatedBlobData.plainData;
},
},

View File

@ -18,6 +18,7 @@ import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql';
import CanCreatePersonalSnippet from '../queries/userPermissions.query.graphql';
import CanCreateProjectSnippet from '../queries/projectPermissions.query.graphql';
import { joinPaths } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql';
export default {
components: {
@ -37,6 +38,7 @@ export default {
},
apollo: {
canCreateSnippet: {
fetchPolicy: fetchPolicies.NO_CACHE,
query() {
return this.snippet.project ? CanCreateProjectSnippet : CanCreatePersonalSnippet;
},

View File

@ -12,18 +12,20 @@ fragment SnippetBase on Snippet {
httpUrlToRepo
sshUrlToRepo
blobs {
binary
name
path
rawPath
size
externalStorage
renderedAsText
simpleViewer {
...BlobViewer
}
richViewer {
...BlobViewer
nodes {
binary
name
path
rawPath
size
externalStorage
renderedAsText
simpleViewer {
...BlobViewer
}
richViewer {
...BlobViewer
}
}
}
userPermissions {

View File

@ -16,7 +16,7 @@ function appFactory(el, Component) {
}
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
defaultClient: createDefaultClient({}, { batchMax: 1 }),
});
const {

View File

@ -11,9 +11,16 @@ export const getSnippetMixin = {
ids: this.snippetGid,
};
},
update: data => data.snippets.edges[0]?.node,
update: data => {
const res = data.snippets.nodes[0];
if (res) {
res.blobs = res.blobs.nodes;
}
return res;
},
result(res) {
this.blobs = res.data.snippets.edges[0]?.node?.blobs || blobsDefault;
this.blobs = res.data.snippets.nodes[0]?.blobs || blobsDefault;
if (this.onSnippetFetch) {
this.onSnippetFetch(res);
}

View File

@ -1,9 +1,9 @@
query SnippetBlobContent($ids: [ID!], $rich: Boolean!) {
query SnippetBlobContent($ids: [ID!], $rich: Boolean!, $paths: [String!]) {
snippets(ids: $ids) {
edges {
node {
id
blobs {
nodes {
id
blobs(paths: $paths) {
nodes {
path
richData @include(if: $rich)
plainData @skip(if: $rich)

View File

@ -4,13 +4,11 @@
query GetSnippetQuery($ids: [ID!]) {
snippets(ids: $ids) {
edges {
node {
...SnippetBase
...SnippetProject
author {
...Author
}
nodes {
...SnippetBase
...SnippetProject
author {
...Author
}
}
}

View File

@ -1,5 +1,5 @@
<script>
import { escape } from 'lodash';
import { escape, last } from 'lodash';
import Tribute from 'tributejs';
import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from '~/lib/utils/common_utils';
@ -12,6 +12,8 @@ const AutoComplete = {
MergeRequests: 'mergeRequests',
};
const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings
function doesCurrentLineStartWith(searchString, fullText, selectionStart) {
const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length;
const currentLine = fullText.split('\n')[currentLineNumber - 1];
@ -79,30 +81,40 @@ const autoCompleteMap = {
return this.members;
},
menuItemTemplate({ original }) {
const rectAvatarClass = original.type === 'Group' ? 'rect-avatar' : '';
const commonClasses = 'gl-avatar gl-avatar-s24 gl-flex-shrink-0';
const noAvatarClasses = `${commonClasses} gl-rounded-small
gl-display-flex gl-align-items-center gl-justify-content-center`;
const avatarClasses = `avatar avatar-inline center s26 ${rectAvatarClass}
gl-display-inline-flex! gl-align-items-center gl-justify-content-center`;
const avatar = original.avatar_url
? `<img class="${commonClasses} gl-avatar-circle" src="${original.avatar_url}" alt="" />`
: `<div class="${noAvatarClasses}" aria-hidden="true">
${original.username.charAt(0).toUpperCase()}</div>`;
const avatarTag = original.avatar_url
? `<img
src="${original.avatar_url}"
alt="${original.username}'s avatar"
class="${avatarClasses}"/>`
: `<div class="${avatarClasses}">${original.username.charAt(0).toUpperCase()}</div>`;
let displayName = original.name;
let parentGroupOrUsername = `@${original.username}`;
const name = escape(original.name);
if (original.type === groupType) {
const splitName = original.name.split(' / ');
displayName = splitName.pop();
parentGroupOrUsername = splitName.pop();
}
const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : '';
const icon = original.mentionsDisabled
? spriteIcon('notifications-off', 's16 gl-vertical-align-middle gl-ml-3')
const disabledMentionsIcon = original.mentionsDisabled
? spriteIcon('notifications-off', 's16 gl-ml-3')
: '';
return `${avatarTag}
${original.username}
<small class="gl-text-small gl-font-weight-normal gl-reset-color">${name}${count}</small>
${icon}`;
return `
<div class="gl-display-flex gl-align-items-center">
${avatar}
<div class="gl-font-sm gl-line-height-normal gl-ml-3">
<div>${escape(displayName)}${count}</div>
<div class="gl-text-gray-700">${escape(parentGroupOrUsername)}</div>
</div>
${disabledMentionsIcon}
</div>
`;
},
},
[AutoComplete.MergeRequests]: {
@ -139,7 +151,8 @@ export default {
{
trigger: '@',
fillAttr: 'username',
lookup: value => value.name + value.username,
lookup: value =>
value.type === groupType ? last(value.name.split(' / ')) : value.name + value.username,
menuItemTemplate: autoCompleteMap[AutoComplete.Members].menuItemTemplate,
values: this.getValues(AutoComplete.Members),
},

View File

@ -50,7 +50,7 @@
right: 15px;
margin-left: auto;
.btn {
.btn:not(.btn-icon) {
padding: 0 10px;
font-size: 13px;
line-height: 28px;
@ -372,7 +372,7 @@ span.idiff {
color: $gl-text-color;
}
.file-actions .btn {
.file-actions .btn:not(.btn-icon) {
padding: 0 10px;
font-size: 13px;
line-height: 28px;

View File

@ -13,6 +13,21 @@
box-shadow: 0 -2px 0 0 var(--white);
cursor: pointer;
.dropdown-menu {
cursor: auto;
}
@media (max-width: map-get($grid-breakpoints, sm)-1) {
.file-header-content {
width: 0;
flex: 1;
}
.file-actions {
margin-left: $gl-spacing-scale-2;
}
}
@media (min-width: map-get($grid-breakpoints, md)) {
// The `+11` is to ensure the file header border shows when scrolled -
// the bottom of the compare-versions header and the top of the file header
@ -420,6 +435,10 @@
margin-left: 0;
border-left: 0;
}
.file-actions .dropdown {
height: 28px;
}
}
table.code {

View File

@ -858,68 +858,28 @@ $note-form-margin-left: 72px;
}
.line-resolve-all-container {
margin: $gl-padding-4;
> div {
white-space: nowrap;
}
.discussion-next-btn {
border-radius: 0;
}
.toggle-all-discussions-btn {
.btn-group .btn:first-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.btn {
line-height: $gl-line-height;
svg {
fill: $gray-500;
}
&.discussion-create-issue-btn {
border-radius: 0;
border-right: 0;
a {
padding: 0;
line-height: 0;
&:hover {
text-decoration: none;
border: 0;
}
}
}
&.discussion-next-btn {
border-right: 0;
}
}
}
.line-resolve-all {
vertical-align: middle;
display: inline-block;
padding: $gl-padding-4 10px;
padding: $gl-padding-8 $gl-padding-12;
background-color: $gray-light;
border: 1px solid $border-color;
border-right: 0;
border-radius: $border-radius-default;
font-size: $gl-btn-small-font-size;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: 0;
.line-resolve-btn {
color: $gray-500;
svg {
vertical-align: text-top;
}
}
font-size: $gl-font-size;
line-height: 1rem;
@include media-breakpoint-down(xs) {
flex: 1;

View File

@ -42,7 +42,7 @@ module Boards
list = board.lists.destroyable.find(params[:id])
service = Boards::Lists::DestroyService.new(board_parent, current_user)
if service.execute(list)
if service.execute(list).success?
head :ok
else
head :unprocessable_entity

View File

@ -44,7 +44,7 @@ module WikiActions
wiki.list_pages(sort: params[:sort], direction: params[:direction])
).page(params[:page])
@wiki_entries = WikiPage.group_by_directory(@wiki_pages)
@wiki_entries = WikiDirectory.group_pages(@wiki_pages)
render 'shared/wikis/pages'
end

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
module Mutations
module Boards
module Lists
class Destroy < ::Mutations::BaseMutation
graphql_name 'DestroyBoardList'
field :list,
Types::BoardListType,
null: true,
description: 'The list after mutation.'
argument :list_id, ::Types::GlobalIDType[::List],
required: true,
loads: Types::BoardListType,
description: 'Global ID of the list to destroy. Only label lists are accepted.'
def resolve(list:)
raise_resource_not_available_error! unless can_admin_list?(list)
response = ::Boards::Lists::DestroyService.new(list.board.resource_parent, current_user)
.execute(list)
{
list: response.success? ? nil : list,
errors: response.errors
}
end
private
def can_admin_list?(list)
return false unless list.present?
Ability.allowed?(current_user, :admin_list, list.board)
end
end
end
end
end

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
module Resolvers
module Snippets
class BlobsResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
alias_method :snippet, :object
argument :paths, [GraphQL::STRING_TYPE],
required: false,
description: 'Paths of the blobs'
def resolve(**args)
authorize!(snippet)
return [snippet.blob] if snippet.empty_repo?
paths = Array(args.fetch(:paths, []))
if paths.empty?
snippet.blobs
else
snippet.repository.blobs_at(transformed_blob_paths(paths))
end
end
def authorized_resource?(snippet)
Ability.allowed?(context[:current_user], :read_snippet, snippet)
end
private
def transformed_blob_paths(paths)
ref = snippet.default_branch
paths.map { |path| [ref, path] }
end
end
end
end

View File

@ -18,6 +18,7 @@ module Types
mount_mutation Mutations::Boards::Issues::IssueMoveList
mount_mutation Mutations::Boards::Lists::Create
mount_mutation Mutations::Boards::Lists::Update
mount_mutation Mutations::Boards::Lists::Destroy
mount_mutation Mutations::Branches::Create, calls_gitaly: true
mount_mutation Mutations::Commits::Create, calls_gitaly: true
mount_mutation Mutations::Discussions::ToggleResolve

View File

@ -69,10 +69,11 @@ module Types
null: false,
deprecated: { reason: 'Use `blobs`', milestone: '13.3' }
field :blobs, type: [Types::Snippets::BlobType],
field :blobs, type: Types::Snippets::BlobType.connection_type,
description: 'Snippet blobs',
calls_gitaly: true,
null: false
null: true,
resolver: Resolvers::Snippets::BlobsResolver
field :ssh_url_to_repo, type: GraphQL::STRING_TYPE,
description: 'SSH URL to the snippet repository',

View File

@ -206,7 +206,7 @@ module IssuablesHelper
end
if access = project.team.human_max_access(issuable.author_id)
output << content_tag(:span, access, class: "user-access-role has-tooltip d-none d-xl-inline-block gl-ml-3 ", title: _("This user is a %{access} of the %{name} project.") % { access: access.downcase, name: project.name })
output << content_tag(:span, access, class: "user-access-role has-tooltip d-none d-xl-inline-block gl-ml-3 ", title: _("This user has the %{access} role in the %{name} project.") % { access: access.downcase, name: project.name })
elsif project.team.contributor?(issuable.author_id)
output << content_tag(:span, _("Contributor"), class: "user-access-role has-tooltip d-none d-xl-inline-block gl-ml-3", title: _("This user has previously committed to the %{name} project.") % { name: project.name })
end

View File

@ -103,7 +103,7 @@ class Wiki
limited = pages.size > limit
pages = pages.first(limit) if limited
[WikiPage.group_by_directory(pages), limited]
[WikiDirectory.group_pages(pages), limited]
end
# Finds a page within the repository based on a tile

View File

@ -3,13 +3,46 @@
class WikiDirectory
include ActiveModel::Validations
attr_accessor :slug, :pages
attr_accessor :slug, :entries
validates :slug, presence: true
def initialize(slug, pages = [])
# Groups a list of wiki pages into a nested collection of WikiPage and WikiDirectory objects,
# preserving the order of the passed pages.
#
# Returns an array with all entries for the toplevel directory.
#
# @param [Array<WikiPage>] pages
# @return [Array<WikiPage, WikiDirectory>]
#
def self.group_pages(pages)
# Build a hash to map paths to created WikiDirectory objects,
# and recursively create them for each level of the path.
# For the toplevel directory we use '' as path, as that's what WikiPage#directory returns.
directories = Hash.new do |_, path|
directories[path] = new(path).tap do |directory|
if path.present?
parent = File.dirname(path)
parent = '' if parent == '.'
directories[parent].entries << directory
end
end
end
pages.each do |page|
directories[page.directory].entries << page
end
directories[''].entries
end
def initialize(slug, entries = [])
@slug = slug
@pages = pages
@entries = entries
end
def title
WikiPage.unhyphenize(File.basename(slug))
end
# Relative path to the partial to be used when rendering collections

View File

@ -31,29 +31,6 @@ class WikiPage
alias_method :==, :eql?
# Sorts and groups pages by directory.
#
# pages - an array of WikiPage objects.
#
# Returns an array of WikiPage and WikiDirectory objects. The entries are
# sorted by alphabetical order (directories and pages inside each directory).
# Pages at the root level come before everything.
def self.group_by_directory(pages)
return [] if pages.blank?
pages.each_with_object([]) do |page, grouped_pages|
next grouped_pages << page unless page.directory.present?
directory = grouped_pages.find do |obj|
obj.is_a?(WikiDirectory) && obj.slug == page.directory
end
next directory.pages << page if directory
grouped_pages << WikiDirectory.new(page.directory, [page])
end
end
def self.unhyphenize(name)
name.gsub(/-+/, ' ')
end

View File

@ -32,15 +32,9 @@ class SnippetPresenter < Gitlab::View::Presenter::Delegated
end
def blob
blobs.first
end
return snippet.blob if snippet.empty_repo?
def blobs
if snippet.empty_repo?
[snippet.blob]
else
snippet.blobs
end
blobs.first
end
private

View File

@ -4,7 +4,9 @@ module Boards
module Lists
class DestroyService < Boards::BaseService
def execute(list)
return false unless list.destroyable?
unless list.destroyable?
return ServiceResponse.error(message: "The list cannot be destroyed. Only label lists can be destroyed.")
end
@board = list.board
@ -12,6 +14,8 @@ module Boards
decrement_higher_lists(list)
remove_list(list)
end
ServiceResponse.success
end
private
@ -26,7 +30,7 @@ module Boards
# rubocop: enable CodeReuse/ActiveRecord
def remove_list(list)
list.destroy
list.destroy!
end
end
end

View File

@ -45,7 +45,7 @@ module Spam
attr_reader :user, :context
def allowlisted?(user)
user.respond_to?(:gitlab_employee) && user.gitlab_employee?
user.try(:gitlab_employee?) || user.try(:gitlab_bot?)
end
def perform_spam_service_check(api)

View File

@ -2,7 +2,7 @@
- if note.noteable_author?(@noteable)
%span{ class: 'note-role user-access-role has-tooltip d-none d-md-inline-block', title: _("This user is the author of this %{noteable}.") % { noteable: @noteable.human_class_name } }= _("Author")
- if access
%span{ class: 'note-role user-access-role has-tooltip', title: _("This user is a %{access} of the %{name} project.") % { access: access.downcase, name: note.project_name } }= access
%span{ class: 'note-role user-access-role has-tooltip', title: _("This user has the %{access} role in the %{name} project.") % { access: access.downcase, name: note.project_name } }= access
- elsif note.contributor?
%span{ class: 'note-role user-access-role has-tooltip', title: _("This user has previously committed to the %{name} project.") % { name: note.project_name } }= _("Contributor")

View File

@ -1,5 +1,5 @@
%li
= link_to wiki_page.title, wiki_page_path(@wiki, wiki_page), data: { qa_selector: 'wiki_page_link', qa_page_name: wiki_page.slug }
= link_to wiki_page.human_title, wiki_page_path(@wiki, wiki_page), data: { qa_selector: 'wiki_page_link', qa_page_name: wiki_page.slug }
%small (#{wiki_page.format})
.float-right
- if wiki_page.last_version

View File

@ -1,4 +1,4 @@
%li{ data: { qa_selector: 'wiki_directory_content' } }
= wiki_directory.slug
= wiki_directory.title
%ul
= render wiki_directory.pages, context: context
= render wiki_directory.entries, context: context

View File

@ -0,0 +1,5 @@
---
title: Show wiki tree structure in sidebar and pages overview
merge_request: 42867
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Destroy issue board list via GraphQL
merge_request: 43081
author:
type: added

View File

@ -1,5 +1,5 @@
---
title: Surface Alert number GFM reference in highlight bar
merge_request: 42832
title: Update alert GFM reference in highlight bar
merge_request: 43104
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: JS client for increment_unique_users API
merge_request: 43084
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Measure npm request forwarding usage
merge_request: 40174
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Move diff header actions into dropdown menu
merge_request:
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Revise tooltip text of note role badge
merge_request: 42771
author: Mycroft Kang @TaehyeokKang
type: Bug fix

View File

@ -1,7 +1,7 @@
---
name: default_merge_ref_for_diffs
introduced_by_url:
rollout_issue_url:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/34472
rollout_issue_url:
type: development
group:
group: group::source code
default_enabled: false

View File

@ -1,7 +1,7 @@
---
name: design_management_allow_dangerous_images
introduced_by_url:
rollout_issue_url:
group:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/16160
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/34279
group: group::knowledge
type: development
default_enabled: false

View File

@ -1,7 +0,0 @@
---
name: export_lfs_objects_projects
introduced_by_url:
rollout_issue_url:
group:
type: development
default_enabled: true

View File

@ -1,7 +1,7 @@
---
name: merge_request_widget_graphql
introduced_by_url:
rollout_issue_url:
group:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38311
rollout_issue_url:
group: group::source code
type: development
default_enabled: false

View File

@ -1,7 +1,7 @@
---
name: product_analytics
introduced_by_url:
rollout_issue_url:
group:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36443
rollout_issue_url:
group: group::product_analytics
type: development
default_enabled: false

View File

@ -1,7 +1,7 @@
---
name: usage_data_incident_management_alert_assigned
introduced_by_url:
rollout_issue_url:
group:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
group: group::health
type: development
default_enabled: true

View File

@ -1,7 +1,7 @@
---
name: usage_data_incident_management_alert_status_changed
introduced_by_url:
rollout_issue_url:
group:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
group: group::health
type: development
default_enabled: true

View File

@ -1,7 +1,7 @@
---
name: usage_data_incident_management_alert_todo
introduced_by_url:
rollout_issue_url:
group:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
group: group::health
type: development
default_enabled: true

View File

@ -1,7 +1,7 @@
---
name: usage_data_incident_management_incident_assigned
introduced_by_url:
rollout_issue_url:
group:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
group: group::health
type: development
default_enabled: true

View File

@ -1,7 +1,7 @@
---
name: usage_data_incident_management_incident_change_confidential
introduced_by_url:
rollout_issue_url:
group:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
group: group::health
type: development
default_enabled: true

View File

@ -1,7 +1,7 @@
---
name: usage_data_incident_management_incident_closed
introduced_by_url:
rollout_issue_url:
group:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
group: group::health
type: development
default_enabled: true

View File

@ -1,7 +1,7 @@
---
name: usage_data_incident_management_incident_comment
introduced_by_url:
rollout_issue_url:
group:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
group: group::health
type: development
default_enabled: true

View File

@ -1,7 +1,7 @@
---
name: usage_data_incident_management_incident_created
introduced_by_url:
rollout_issue_url:
group:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
group: group::health
type: development
default_enabled: true

View File

@ -1,7 +1,7 @@
---
name: usage_data_incident_management_incident_relate
introduced_by_url:
rollout_issue_url:
group:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
group: group::health
type: development
default_enabled: true

View File

@ -1,7 +1,7 @@
---
name: usage_data_incident_management_incident_reopened
introduced_by_url:
rollout_issue_url:
group:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
group: group::health
type: development
default_enabled: true

View File

@ -1,7 +1,7 @@
---
name: usage_data_incident_management_incident_todo
introduced_by_url:
rollout_issue_url:
group:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
group: group::health
type: development
default_enabled: true

View File

@ -1,7 +1,7 @@
---
name: usage_data_incident_management_incident_unrelate
introduced_by_url:
rollout_issue_url:
group:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
group: group::health
type: development
default_enabled: true

View File

@ -1,7 +1,7 @@
---
name: usage_data_incident_management_incident_zoom_meeting
introduced_by_url:
rollout_issue_url:
group:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
group: group::health
type: development
default_enabled: true

View File

@ -1,7 +1,7 @@
---
name: view_diffs_file_by_file
introduced_by_url:
rollout_issue_url:
group:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35223
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229848
group: group::source code
type: development
default_enabled: true

View File

@ -1,7 +1,7 @@
---
name: web_ide_primary_edit
introduced_by_url:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35957
rollout_issue_url:
group:
group: group::editor
type: development
default_enabled: false

View File

@ -1,7 +1,7 @@
---
name: widget_visibility_polling
introduced_by_url:
rollout_issue_url:
group:
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/29318
rollout_issue_url:
group: group::source code
type: development
default_enabled: true

View File

@ -25,10 +25,12 @@ if app.config.public_file_server.enabled
}
if Rails.env.development?
# /assets are proxied through a Rails middlware to the Webpack
# server, so we have to use the local Rails settings.
settings.merge!(
host: Gitlab.config.gitlab.host,
port: Gitlab.config.gitlab.port,
https: false
https: Gitlab.config.gitlab.https
)
app.config.middleware.insert_before(
Gitlab::Middleware::Static,

View File

@ -17,21 +17,21 @@ full list of reference architectures, see
| Service | Nodes | Configuration | GCP | AWS | Azure |
|--------------------------------------------|-------------|-------------------------|-----------------|-------------|----------|
| External load balancing node | 1 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Consul | 3 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| PostgreSQL | 3 | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| PgBouncer | 3 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Internal load balancing node | 1 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Redis - Cache | 3 | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| Redis - Queues / Shared State | 3 | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| Redis Sentinel - Cache | 3 | 1 vCPU, 1.7GB memory | g1-small | t2.small | B1MS |
| Redis Sentinel - Queues / Shared State | 3 | 1 vCPU, 1.7GB memory | g1-small | t2.small | B1MS |
| Gitaly | 2 (minimum) | 16 vCPU, 60GB memory | n1-standard-16 | m5.4xlarge | D16s v3 |
| Sidekiq | 4 | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| GitLab Rails | 3 | 32 vCPU, 28.8GB memory | n1-highcpu-32 | c5.9xlarge | F32s v2 |
| Monitoring node | 1 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
| Object Storage | n/a | n/a | n/a | n/a | n/a |
| NFS Server | 1 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
| External load balancing node | 1 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Consul | 3 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| PostgreSQL | 3 | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| PgBouncer | 3 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Internal load balancing node | 1 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Redis - Cache | 3 | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| Redis - Queues / Shared State | 3 | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| Redis Sentinel - Cache | 3 | 1 vCPU, 1.7 GB memory | g1-small | t2.small | B1MS |
| Redis Sentinel - Queues / Shared State | 3 | 1 vCPU, 1.7 GB memory | g1-small | t2.small | B1MS |
| Gitaly | 2 (minimum) | 16 vCPU, 60 GB memory | n1-standard-16 | m5.4xlarge | D16s v3 |
| Sidekiq | 4 | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| GitLab Rails | 3 | 32 vCPU, 28.8 GB memory | n1-highcpu-32 | c5.9xlarge | F32s v2 |
| Monitoring node | 1 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
| Object storage | n/a | n/a | n/a | n/a | n/a |
| NFS server | 1 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
The Google Cloud Platform (GCP) architectures were built and tested using the
[Intel Xeon E5 v3 (Haswell)](https://cloud.google.com/compute/docs/cpu-platforms)
@ -40,11 +40,10 @@ or higher, are required for your CPU or node counts. For more information, see
our [Sysbench](https://github.com/akopytov/sysbench)-based
[CPU benchmark](https://gitlab.com/gitlab-org/quality/performance/-/wikis/Reference-Architectures/GCP-CPU-Benchmarks).
For data objects (such as LFS, Uploads, or Artifacts), an
[object storage service](#configure-the-object-storage) is recommended instead
of NFS where possible, due to better performance and availability. Since this
doesn't require a node to be set up, *Object Storage* is noted as not
applicable (n/a) in the previous table.
Due to better performance and availability, for data objects (such as LFS,
uploads, or artifacts), using an [object storage service](#configure-the-object-storage)
is recommended instead of using NFS. Using an object storage service also
doesn't require you to provision and maintain a node.
## Setup components

View File

@ -20,8 +20,8 @@ many organizations .
| Users | Configuration | GCP | AWS | Azure |
|--------------|-------------------------|----------------|-----------------|----------------|
| Up to 500 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
| Up to 1,000 | 8 vCPU, 7.2GB memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
| Up to 500 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
| Up to 1,000 | 8 vCPU, 7.2 GB memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
The Google Cloud Platform (GCP) architectures were built and tested using the
[Intel Xeon E5 v3 (Haswell)](https://cloud.google.com/compute/docs/cpu-platforms)
@ -30,9 +30,9 @@ or higher, are required for your CPU or node counts. For more information, see
our [Sysbench](https://github.com/akopytov/sysbench)-based
[CPU benchmark](https://gitlab.com/gitlab-org/quality/performance/-/wikis/Reference-Architectures/GCP-CPU-Benchmarks).
In addition to the stated configurations, we recommend having at least 2GB of
In addition to the stated configurations, we recommend having at least 2 GB of
swap on your server, even if you currently have enough available memory. Having
swap will help reduce the chance of errors occurring if your available memory
swap helps to reduce the chance of errors occurring if your available memory
changes. We also recommend configuring the kernel's swappiness setting to a
lower value (such as `10`) to make the most of your memory, while still having
the swap available when needed.

View File

@ -17,21 +17,21 @@ full list of reference architectures, see
| Service | Nodes | Configuration | GCP | AWS | Azure |
|-----------------------------------------|-------------|-------------------------|-----------------|-------------|----------|
| External load balancing node | 1 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
| Consul | 3 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| PostgreSQL | 3 | 8 vCPU, 30GB memory | n1-standard-8 | m5.2xlarge | D8s v3 |
| PgBouncer | 3 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Internal load balancing node | 1 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Redis - Cache | 3 | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| Redis - Queues / Shared State | 3 | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| Redis Sentinel - Cache | 3 | 1 vCPU, 1.7GB memory | g1-small | t2.small | B1MS |
| Redis Sentinel - Queues / Shared State | 3 | 1 vCPU, 1.7GB memory | g1-small | t2.small | B1MS |
| Gitaly | 2 (minimum) | 32 vCPU, 120GB memory | n1-standard-32 | m5.8xlarge | D32s v3 |
| Sidekiq | 4 | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| GitLab Rails | 5 | 32 vCPU, 28.8GB memory | n1-highcpu-32 | c5.9xlarge | F32s v2 |
| Monitoring node | 1 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
| Object Storage | n/a | n/a | n/a | n/a | n/a |
| NFS Server | 1 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
| External load balancing node | 1 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
| Consul | 3 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| PostgreSQL | 3 | 8 vCPU, 30 GB memory | n1-standard-8 | m5.2xlarge | D8s v3 |
| PgBouncer | 3 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Internal load balancing node | 1 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Redis - Cache | 3 | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| Redis - Queues / Shared State | 3 | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| Redis Sentinel - Cache | 3 | 1 vCPU, 1.7 GB memory | g1-small | t2.small | B1MS |
| Redis Sentinel - Queues / Shared State | 3 | 1 vCPU, 1.7 GB memory | g1-small | t2.small | B1MS |
| Gitaly | 2 (minimum) | 32 vCPU, 120 GB memory | n1-standard-32 | m5.8xlarge | D32s v3 |
| Sidekiq | 4 | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| GitLab Rails | 5 | 32 vCPU, 28.8 GB memory | n1-highcpu-32 | c5.9xlarge | F32s v2 |
| Monitoring node | 1 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
| Object storage | n/a | n/a | n/a | n/a | n/a |
| NFS server | 1 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
The Google Cloud Platform (GCP) architectures were built and tested using the
[Intel Xeon E5 v3 (Haswell)](https://cloud.google.com/compute/docs/cpu-platforms)
@ -40,11 +40,10 @@ or higher, are required for your CPU or node counts. For more information, see
our [Sysbench](https://github.com/akopytov/sysbench)-based
[CPU benchmark](https://gitlab.com/gitlab-org/quality/performance/-/wikis/Reference-Architectures/GCP-CPU-Benchmarks).
For data objects (such as LFS, Uploads, or Artifacts), an
[object storage service](#configure-the-object-storage) is recommended instead
of NFS where possible, due to better performance and availability. Since this
doesn't require a node to be set up, *Object Storage* is noted as not
applicable (n/a) in the previous table.
Due to better performance and availability, for data objects (such as LFS,
uploads, or artifacts), using an [object storage service](#configure-the-object-storage)
is recommended instead of using NFS. Using an object storage service also
doesn't require you to provision and maintain a node.
## Setup components

View File

@ -17,14 +17,14 @@ For a full list of reference architectures, see
| Service | Nodes | Configuration | GCP | AWS | Azure |
|------------------------------------------|--------|-------------------------|----------------|--------------|---------|
| Load balancer | 1 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| PostgreSQL | 1 | 2 vCPU, 7.5GB memory | n1-standard-2 | m5.large | D2s v3 |
| Redis | 1 | 1 vCPU, 3.75GB memory | n1-standard-1 | m5.large | D2s v3 |
| Gitaly | 1 | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| GitLab Rails | 2 | 8 vCPU, 7.2GB memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
| Monitoring node | 1 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Load balancer | 1 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| PostgreSQL | 1 | 2 vCPU, 7.5 GB memory | n1-standard-2 | m5.large | D2s v3 |
| Redis | 1 | 1 vCPU, 3.75 GB memory | n1-standard-1 | m5.large | D2s v3 |
| Gitaly | 1 | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| GitLab Rails | 2 | 8 vCPU, 7.2 GB memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
| Monitoring node | 1 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Object storage | n/a | n/a | n/a | n/a | n/a |
| NFS server (optional, not recommended) | 1 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
| NFS server (optional, not recommended) | 1 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
The Google Cloud Platform (GCP) architectures were built and tested using the
[Intel Xeon E5 v3 (Haswell)](https://cloud.google.com/compute/docs/cpu-platforms)

View File

@ -24,18 +24,18 @@ costly-to-operate environment by using the
| Service | Nodes | Configuration | GCP | AWS | Azure |
|--------------------------------------------|-------------|-----------------------|----------------|-------------|---------|
| External load balancing node | 1 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Redis | 3 | 2 vCPU, 7.5GB memory | n1-standard-2 | m5.large | D2s v3 |
| Consul + Sentinel | 3 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| PostgreSQL | 3 | 2 vCPU, 7.5GB memory | n1-standard-2 | m5.large | D2s v3 |
| PgBouncer | 3 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Internal load balancing node | 1 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Gitaly | 2 (minimum) | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| Sidekiq | 4 | 2 vCPU, 7.5GB memory | n1-standard-2 | m5.large | D2s v3 |
| GitLab Rails | 3 | 8 vCPU, 7.2GB memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
| Monitoring node | 1 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Object Storage | n/a | n/a | n/a | n/a | n/a |
| NFS Server (optional, not recommended) | 1 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
| External load balancing node | 1 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Redis | 3 | 2 vCPU, 7.5 GB memory | n1-standard-2 | m5.large | D2s v3 |
| Consul + Sentinel | 3 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| PostgreSQL | 3 | 2 vCPU, 7.5 GB memory | n1-standard-2 | m5.large | D2s v3 |
| PgBouncer | 3 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Internal load balancing node | 1 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Gitaly | 2 (minimum) | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| Sidekiq | 4 | 2 vCPU, 7.5 GB memory | n1-standard-2 | m5.large | D2s v3 |
| GitLab Rails | 3 | 8 vCPU, 7.2 GB memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
| Monitoring node | 1 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Object storage | n/a | n/a | n/a | n/a | n/a |
| NFS server (optional, not recommended) | 1 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
The Google Cloud Platform (GCP) architectures were built and tested using the
[Intel Xeon E5 v3 (Haswell)](https://cloud.google.com/compute/docs/cpu-platforms)
@ -44,11 +44,10 @@ or higher, are required for your CPU or node counts. For more information, see
our [Sysbench](https://github.com/akopytov/sysbench)-based
[CPU benchmark](https://gitlab.com/gitlab-org/quality/performance/-/wikis/Reference-Architectures/GCP-CPU-Benchmarks).
For data objects (such as LFS, Uploads, or Artifacts), an
[object storage service](#configure-the-object-storage) is recommended instead
of NFS where possible, due to better performance and availability. Since this
doesn't require a node to be set up, *Object Storage* is noted as not
applicable (n/a) in the previous table.
Due to better performance and availability, for data objects (such as LFS,
uploads, or artifacts), using an [object storage service](#configure-the-object-storage)
is recommended instead of using NFS. Using an object storage service also
doesn't require you to provision and maintain a node.
## Setup components

View File

@ -17,21 +17,21 @@ full list of reference architectures, see
| Service | Nodes | Configuration | GCP | AWS | Azure |
|-----------------------------------------|-------------|-------------------------|-----------------|--------------|----------|
| External load balancing node | 1 | 8 vCPU, 7.2GB memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
| Consul | 3 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| PostgreSQL | 3 | 16 vCPU, 60GB memory | n1-standard-16 | m5.4xlarge | D16s v3 |
| PgBouncer | 3 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Internal load balancing node | 1 | 8 vCPU, 7.2GB memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
| Redis - Cache | 3 | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| Redis - Queues / Shared State | 3 | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| Redis Sentinel - Cache | 3 | 1 vCPU, 1.7GB memory | g1-small | t2.small | B1MS |
| Redis Sentinel - Queues / Shared State | 3 | 1 vCPU, 1.7GB memory | g1-small | t2.small | B1MS |
| Gitaly | 2 (minimum) | 64 vCPU, 240GB memory | n1-standard-64 | m5.16xlarge | D64s v3 |
| Sidekiq | 4 | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| GitLab Rails | 12 | 32 vCPU, 28.8GB memory | n1-highcpu-32 | c5.9xlarge | F32s v2 |
| Monitoring node | 1 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
| Object Storage | n/a | n/a | n/a | n/a | n/a |
| NFS Server | 1 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
| External load balancing node | 1 | 8 vCPU, 7.2 GB memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
| Consul | 3 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| PostgreSQL | 3 | 16 vCPU, 60 GB memory | n1-standard-16 | m5.4xlarge | D16s v3 |
| PgBouncer | 3 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Internal load balancing node | 1 | 8 vCPU, 7.2 GB memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
| Redis - Cache | 3 | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| Redis - Queues / Shared State | 3 | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| Redis Sentinel - Cache | 3 | 1 vCPU, 1.7 GB memory | g1-small | t2.small | B1MS |
| Redis Sentinel - Queues / Shared State | 3 | 1 vCPU, 1.7 GB memory | g1-small | t2.small | B1MS |
| Gitaly | 2 (minimum) | 64 vCPU, 240 GB memory | n1-standard-64 | m5.16xlarge | D64s v3 |
| Sidekiq | 4 | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
| GitLab Rails | 12 | 32 vCPU, 28.8 GB memory | n1-highcpu-32 | c5.9xlarge | F32s v2 |
| Monitoring node | 1 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
| Object storage | n/a | n/a | n/a | n/a | n/a |
| NFS server | 1 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
The Google Cloud Platform (GCP) architectures were built and tested using the
[Intel Xeon E5 v3 (Haswell)](https://cloud.google.com/compute/docs/cpu-platforms)
@ -40,11 +40,10 @@ or higher, are required for your CPU or node counts. For more information, see
our [Sysbench](https://github.com/akopytov/sysbench)-based
[CPU benchmark](https://gitlab.com/gitlab-org/quality/performance/-/wikis/Reference-Architectures/GCP-CPU-Benchmarks).
For data objects (such as LFS, Uploads, or Artifacts), an
[object storage service](#configure-the-object-storage) is recommended instead
of NFS where possible, due to better performance and availability. Since this
doesn't require a node to be set up, *Object Storage* is noted as not
applicable (n/a) in the previous table.
Due to better performance and availability, for data objects (such as LFS,
uploads, or artifacts), using an [object storage service](#configure-the-object-storage)
is recommended instead of using NFS. Using an object storage service also
doesn't require you to provision and maintain a node.
## Setup components

View File

@ -24,18 +24,18 @@ costly-to-operate environment by using the
| Service | Nodes | Configuration | GCP | AWS | Azure |
|--------------------------------------------|-------------|-------------------------|----------------|-------------|----------|
| External load balancing node | 1 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Redis | 3 | 2 vCPU, 7.5GB memory | n1-standard-2 | m5.large | D2s v3 |
| Consul + Sentinel | 3 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| PostgreSQL | 3 | 2 vCPU, 7.5GB memory | n1-standard-2 | m5.large | D2s v3 |
| PgBouncer | 3 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Internal load balancing node | 1 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Gitaly | 2 (minimum) | 8 vCPU, 30GB memory | n1-standard-8 | m5.2xlarge | D8s v3 |
| Sidekiq | 4 | 2 vCPU, 7.5GB memory | n1-standard-2 | m5.large | D2s v3 |
| GitLab Rails | 3 | 16 vCPU, 14.4GB memory | n1-highcpu-16 | c5.4xlarge | F16s v2 |
| Monitoring node | 1 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Object Storage | n/a | n/a | n/a | n/a | n/a |
| NFS Server (optional, not recommended) | 1 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
| External load balancing node | 1 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Redis | 3 | 2 vCPU, 7.5 GB memory | n1-standard-2 | m5.large | D2s v3 |
| Consul + Sentinel | 3 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| PostgreSQL | 3 | 2 vCPU, 7.5 GB memory | n1-standard-2 | m5.large | D2s v3 |
| PgBouncer | 3 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Internal load balancing node | 1 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Gitaly | 2 (minimum) | 8 vCPU, 30 GB memory | n1-standard-8 | m5.2xlarge | D8s v3 |
| Sidekiq | 4 | 2 vCPU, 7.5 GB memory | n1-standard-2 | m5.large | D2s v3 |
| GitLab Rails | 3 | 16 vCPU, 14.4 GB memory | n1-highcpu-16 | c5.4xlarge | F16s v2 |
| Monitoring node | 1 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Object storage | n/a | n/a | n/a | n/a | n/a |
| NFS server (optional, not recommended) | 1 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
The Google Cloud Platform (GCP) architectures were built and tested using the
[Intel Xeon E5 v3 (Haswell)](https://cloud.google.com/compute/docs/cpu-platforms)
@ -44,11 +44,10 @@ or higher, are required for your CPU or node counts. For more information, see
our [Sysbench](https://github.com/akopytov/sysbench)-based
[CPU benchmark](https://gitlab.com/gitlab-org/quality/performance/-/wikis/Reference-Architectures/GCP-CPU-Benchmarks).
For data objects (such as LFS, Uploads, or Artifacts), an
[object storage service](#configure-the-object-storage) is recommended instead
of NFS where possible, due to better performance and availability. Since this
doesn't require a node to be set up, *Object Storage* is noted as not
applicable (n/a) in the previous table.
Due to better performance and availability, for data objects (such as LFS,
uploads, or artifacts), using an [object storage service](#configure-the-object-storage)
is recommended instead of using NFS. Using an object storage service also
doesn't require you to provision and maintain a node.
## Setup components

View File

@ -4495,6 +4495,41 @@ input DestroyBoardInput {
id: BoardID!
}
"""
Autogenerated input type of DestroyBoardList
"""
input DestroyBoardListInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Global ID of the list to destroy. Only label lists are accepted.
"""
listId: ListID!
}
"""
Autogenerated return type of DestroyBoardList
"""
type DestroyBoardListPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
The list after mutation.
"""
list: BoardList
}
"""
Autogenerated return type of DestroyBoard
"""
@ -9280,6 +9315,11 @@ Identifier of Label
"""
scalar LabelID
"""
Identifier of List
"""
scalar ListID
"""
List limit metric setting
"""
@ -10835,6 +10875,7 @@ type Mutation {
designManagementMove(input: DesignManagementMoveInput!): DesignManagementMovePayload
designManagementUpload(input: DesignManagementUploadInput!): DesignManagementUploadPayload
destroyBoard(input: DestroyBoardInput!): DestroyBoardPayload
destroyBoardList(input: DestroyBoardListInput!): DestroyBoardListPayload
destroyNote(input: DestroyNoteInput!): DestroyNotePayload
destroySnippet(input: DestroySnippetInput!): DestroySnippetPayload
@ -16250,7 +16291,32 @@ type Snippet implements Noteable {
"""
Snippet blobs
"""
blobs: [SnippetBlob!]!
blobs(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
Paths of the blobs
"""
paths: [String!]
): SnippetBlobConnection
"""
Timestamp this snippet was created
@ -16473,6 +16539,41 @@ input SnippetBlobActionInputType {
previousPath: String
}
"""
The connection type for SnippetBlob.
"""
type SnippetBlobConnection {
"""
A list of edges.
"""
edges: [SnippetBlobEdge]
"""
A list of nodes.
"""
nodes: [SnippetBlob]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type SnippetBlobEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: SnippetBlob
}
"""
Represents how the blob content should be displayed
"""

View File

@ -12400,6 +12400,108 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "DestroyBoardListInput",
"description": "Autogenerated input type of DestroyBoardList",
"fields": null,
"inputFields": [
{
"name": "listId",
"description": "Global ID of the list to destroy. Only label lists are accepted.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ListID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "DestroyBoardListPayload",
"description": "Autogenerated return type of DestroyBoardList",
"fields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "list",
"description": "The list after mutation.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "BoardList",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "DestroyBoardPayload",
@ -25724,6 +25826,16 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "ListID",
"description": "Identifier of List",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "ListLimitMetric",
@ -31083,6 +31195,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "destroyBoardList",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "DestroyBoardListInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "DestroyBoardListPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "destroyNote",
"description": null,
@ -47549,24 +47688,69 @@
"name": "blobs",
"description": "Snippet blobs",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
{
"name": "paths",
"description": "Paths of the blobs",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "SnippetBlob",
"ofType": null
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"defaultValue": null
},
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "SnippetBlobConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
@ -48220,6 +48404,118 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SnippetBlobConnection",
"description": "The connection type for SnippetBlob.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "SnippetBlobEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "SnippetBlob",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": "Information to aid in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SnippetBlobEdge",
"description": "An edge in a connection.",
"fields": [
{
"name": "cursor",
"description": "A cursor for use in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": "The item at the end of the edge.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "SnippetBlob",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SnippetBlobViewer",

View File

@ -740,6 +740,16 @@ A specific version in which designs were added, modified or deleted.
| `id` | ID! | ID of the design version |
| `sha` | ID! | SHA of the design version |
### DestroyBoardListPayload
Autogenerated return type of DestroyBoardList.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `list` | BoardList | The list after mutation. |
### DestroyBoardPayload
Autogenerated return type of DestroyBoard.
@ -2303,7 +2313,6 @@ Represents a snippet entry.
| ----- | ---- | ----------- |
| `author` | User | The owner of the snippet |
| `blob` **{warning-solid}** | SnippetBlob! | **Deprecated:** Use `blobs`. Deprecated in 13.3 |
| `blobs` | SnippetBlob! => Array | Snippet blobs |
| `createdAt` | Time! | Timestamp this snippet was created |
| `description` | String | Description of the snippet |
| `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` |

View File

@ -220,3 +220,53 @@ Example response:
"closed_at": null
}
```
## Revert vulnerability to detected state
Reverts a given vulnerability to detected state. Returns status code `304` if the vulnerability is already in detected state.
If an authenticated user does not have permission to
[revert vulnerability to detected state](../user/permissions.md#project-members-permissions),
this request will result in a `403` status code.
```plaintext
POST /vulnerabilities/:id/revert
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer or string | yes | The ID of a vulnerability to revert to detected state |
```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/vulnerabilities/5/dismiss"
```
Example response:
```json
{
"id": 2,
"title": "Predictable pseudorandom number generator",
"description": null,
"state": "detected",
"severity": "medium",
"confidence": "medium",
"report_type": "sast",
"project": {
"id": 32,
"name": "security-reports",
"full_path": "/gitlab-examples/security/security-reports",
"full_name": "gitlab-examples / security / security-reports"
},
"author_id": 1,
"updated_by_id": null,
"last_edited_by_id": null,
"closed_by_id": null,
"start_date": null,
"due_date": null,
"created_at": "2019-10-13T15:08:40.219Z",
"updated_at": "2019-10-13T15:09:40.382Z",
"last_edited_at": null,
"closed_at": null
}
```

View File

@ -360,7 +360,7 @@ Implemented using Redis methods [PFADD](https://redis.io/commands/pfadd) and [PF
| `event` | string | yes | The event name it should be tracked |
Response
w
Return 200 if tracking failed for any reason.
- `200` if event was tracked or any errors
@ -368,6 +368,18 @@ Implemented using Redis methods [PFADD](https://redis.io/commands/pfadd) and [PF
- `401 Unauthorized` if user is not authenticated
- `403 Forbidden` for invalid CSRF token provided
1. Track events using JavaScript/Vue API helper which calls the API above
Example usage for an existing event already defined in [known events](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/usage_data_counters/known_events.yml):
Note that `usage_data_api` and `usage_data_#{event_name}` should be enabled in order to rack events using API.
```javascript
import api from '~/api';
api.trackRedisHllUserEvent('my_already_defined_event_name'),
```
1. Track event using base module `Gitlab::UsageDataCounters::HLLRedisCounter.track_event(entity_id, event_name)`.
Arguments:

View File

@ -116,6 +116,7 @@ The following table depicts the various user permission levels in a project.
| Create vulnerability from vulnerability finding **(ULTIMATE)** | | | ✓ | ✓ | ✓ |
| Resolve vulnerability **(ULTIMATE)** | | | ✓ | ✓ | ✓ |
| Dismiss vulnerability **(ULTIMATE)** | | | ✓ | ✓ | ✓ |
| Revert vulnerability to detected state **(ULTIMATE)** | | | ✓ | ✓ | ✓ |
| Apply code change suggestions | | | ✓ | ✓ | ✓ |
| Create and edit wiki pages | | | ✓ | ✓ | ✓ |
| Rewrite/remove Git tags | | | ✓ | ✓ | ✓ |

View File

@ -45,7 +45,7 @@ module API
def destroy_list(list)
destroy_conditionally!(list) do |list|
service = ::Boards::Lists::DestroyService.new(board_parent, current_user)
unless service.execute(list)
if service.execute(list).error?
render_api_error!({ error: 'List could not be deleted!' }, 400)
end
end

View File

@ -10,6 +10,7 @@ module API
def redirect_registry_request(forward_to_registry, package_type, options)
if forward_to_registry && redirect_registry_request_available?
track_event("#{package_type}_request_forward")
redirect(registry_url(package_type, options))
else
yield

View File

@ -49,6 +49,7 @@ module Gitlab
push_frontend_feature_flag(:snippets_edit_vue, default_enabled: true)
push_frontend_feature_flag(:webperf_experiment, default_enabled: false)
push_frontend_feature_flag(:snippets_binary_blob, default_enabled: false)
push_frontend_feature_flag(:usage_data_api, default_enabled: false)
# Startup CSS feature is a special one as it can be enabled by means of cookies and params
gon.push({ features: { 'startupCss' => use_startup_css? } }, true)

View File

@ -21,10 +21,10 @@ module Gitlab
save_lfs_object(lfs_object)
end
append_lfs_json_for_batch(batch) if write_lfs_json_enabled?
append_lfs_json_for_batch(batch)
end
write_lfs_json if write_lfs_json_enabled?
write_lfs_json
true
rescue => e
@ -35,10 +35,6 @@ module Gitlab
private
def write_lfs_json_enabled?
::Feature.enabled?(:export_lfs_objects_projects, default_enabled: true)
end
def save_lfs_object(lfs_object)
if lfs_object.local_store?
copy_file_for_lfs_object(lfs_object)

View File

@ -2192,9 +2192,6 @@ msgid_plural "Alerts"
msgstr[0] ""
msgstr[1] ""
msgid "Alert #%{alertId}"
msgstr ""
msgid "AlertManagement|Acknowledged"
msgstr ""
@ -4486,9 +4483,6 @@ msgstr ""
msgid "Can't create snippet: %{err}"
msgstr ""
msgid "Can't edit as source branch was deleted"
msgstr ""
msgid "Can't fetch content for the blob: %{err}"
msgstr ""
@ -6385,6 +6379,9 @@ msgstr ""
msgid "Collapse"
msgstr ""
msgid "Collapse all threads"
msgstr ""
msgid "Collapse approvers"
msgstr ""
@ -9321,9 +9318,6 @@ msgstr ""
msgid "Edit environment"
msgstr ""
msgid "Edit file in..."
msgstr ""
msgid "Edit files in the editor and commit changes here"
msgstr ""
@ -10461,6 +10455,9 @@ msgstr ""
msgid "Expand all files"
msgstr ""
msgid "Expand all threads"
msgstr ""
msgid "Expand approvers"
msgstr ""
@ -12994,6 +12991,9 @@ msgid_plural "Hide charts"
msgstr[0] ""
msgstr[1] ""
msgid "Hide comments on this file"
msgstr ""
msgid "Hide details"
msgstr ""
@ -15838,9 +15838,6 @@ msgstr ""
msgid "MergeRequests|Thread will be unresolved"
msgstr ""
msgid "MergeRequests|Toggle comments for this file"
msgstr ""
msgid "MergeRequests|View file @ %{commitId}"
msgstr ""
@ -23417,6 +23414,9 @@ msgstr ""
msgid "Show comments"
msgstr ""
msgid "Show comments on this file"
msgstr ""
msgid "Show comments only"
msgstr ""
@ -26296,7 +26296,7 @@ msgstr ""
msgid "This user has previously committed to the %{name} project."
msgstr ""
msgid "This user is a %{access} of the %{name} project."
msgid "This user has the %{access} role in the %{name} project."
msgstr ""
msgid "This user is the author of this %{noteable}."
@ -26810,9 +26810,6 @@ msgstr ""
msgid "Toggle Sidebar"
msgstr ""
msgid "Toggle all threads"
msgstr ""
msgid "Toggle backtrace"
msgstr ""
@ -28362,6 +28359,12 @@ msgstr ""
msgid "VulnerabilityManagement|Could not process %{issueReference}: %{errorMessage}."
msgstr ""
msgid "VulnerabilityManagement|Detected"
msgstr ""
msgid "VulnerabilityManagement|Needs triage"
msgstr ""
msgid "VulnerabilityManagement|Something went wrong while trying to delete the comment. Please try again later."
msgstr ""

View File

@ -4,7 +4,7 @@ module QA
RSpec.describe 'Create' do
context 'Wiki' do
let(:initial_wiki) { Resource::Wiki::ProjectPage.fabricate_via_api! }
let(:new_path) { "a/new/path" }
let(:new_path) { "a/new/path-with-spaces" }
before do
Flow::Login.sign_in
@ -23,7 +23,9 @@ module QA
Page::Project::Wiki::Edit.perform(&:click_save_changes)
Page::Project::Wiki::Show.perform do |wiki|
expect(wiki).to have_directory(new_path)
expect(wiki).to have_directory('a')
expect(wiki).to have_directory('new')
expect(wiki).to have_directory('path with spaces')
end
end
end

View File

@ -260,6 +260,17 @@ RSpec.describe Boards::ListsController do
end
end
context 'with an error service response' do
it 'returns an unprocessable entity response' do
allow(Boards::Lists::DestroyService).to receive(:new)
.and_return(double(execute: ServiceResponse.error(message: 'error')))
remove_board_list user: user, board: board, list: planning
expect(response).to have_gitlab_http_status(:unprocessable_entity)
end
end
def remove_board_list(user:, board:, list:)
sign_in(user)

View File

@ -2,18 +2,18 @@
require 'spec_helper'
RSpec.describe GroupsController do
RSpec.describe GroupsController, factory_default: :keep do
include ExternalAuthorizationServiceHelpers
let(:user) { create(:user) }
let(:admin) { create(:admin) }
let(:group) { create(:group, :public) }
let(:project) { create(:project, namespace: group) }
let!(:group_member) { create(:group_member, group: group, user: user) }
let!(:owner) { group.add_owner(create(:user)).user }
let!(:maintainer) { group.add_maintainer(create(:user)).user }
let!(:developer) { group.add_developer(create(:user)).user }
let!(:guest) { group.add_guest(create(:user)).user }
let_it_be_with_refind(:group) { create_default(:group, :public) }
let_it_be_with_refind(:project) { create(:project, namespace: group) }
let_it_be(:user) { create(:user) }
let_it_be(:admin) { create(:admin) }
let_it_be(:group_member) { create(:group_member, group: group, user: user) }
let_it_be(:owner) { group.add_owner(create(:user)).user }
let_it_be(:maintainer) { group.add_maintainer(create(:user)).user }
let_it_be(:developer) { group.add_developer(create(:user)).user }
let_it_be(:guest) { group.add_guest(create(:user)).user }
shared_examples 'member with ability to create subgroups' do
it 'renders the new page' do
@ -57,7 +57,6 @@ RSpec.describe GroupsController do
describe 'GET #show' do
before do
sign_in(user)
project
end
let(:format) { :html }
@ -82,7 +81,6 @@ RSpec.describe GroupsController do
describe 'GET #details' do
before do
sign_in(user)
project
end
let(:format) { :html }
@ -131,12 +129,9 @@ RSpec.describe GroupsController do
end
describe 'GET #activity' do
render_views
context 'as json' do
before do
sign_in(user)
project
end
it 'includes events from all projects in group and subgroups', :sidekiq_might_not_need_inline do
@ -157,10 +152,6 @@ RSpec.describe GroupsController do
end
context 'when user has no permission to see the event' do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, group: group) }
let(:project_with_restricted_access) do
create(:project, :public, issues_access_level: ProjectFeature::PRIVATE, group: group)
end
@ -552,8 +543,6 @@ RSpec.describe GroupsController do
end
context 'when there is a conflicting group path' do
render_views
let!(:conflict_group) { create(:group, path: SecureRandom.hex(12) ) }
let!(:old_name) { group.name }
@ -794,6 +783,7 @@ RSpec.describe GroupsController do
context 'when transferring to a subgroup goes right' do
let(:new_parent_group) { create(:group, :public) }
let(:group) { create(:group, :public) }
let!(:group_member) { create(:group_member, :owner, group: group, user: user) }
let!(:new_parent_group_member) { create(:group_member, :owner, group: new_parent_group, user: user) }
@ -805,11 +795,8 @@ RSpec.describe GroupsController do
}
end
it 'returns a notice' do
it 'returns a notice and redirects to the new path' do
expect(flash[:notice]).to eq("Group '#{group.name}' was successfully transferred.")
end
it 'redirects to the new path' do
expect(response).to redirect_to("/#{new_parent_group.path}/#{group.path}")
end
end
@ -826,17 +813,15 @@ RSpec.describe GroupsController do
}
end
it 'returns a notice' do
it 'returns a notice and redirects to the new path' do
expect(flash[:notice]).to eq("Group '#{group.name}' was successfully transferred.")
end
it 'redirects to the new path' do
expect(response).to redirect_to("/#{group.path}")
end
end
context 'When the transfer goes wrong' do
let(:new_parent_group) { create(:group, :public) }
let(:group) { create(:group, :public) }
let!(:group_member) { create(:group_member, :owner, group: group, user: user) }
let!(:new_parent_group_member) { create(:group_member, :owner, group: new_parent_group, user: user) }
@ -850,17 +835,15 @@ RSpec.describe GroupsController do
}
end
it 'returns an alert' do
it 'returns an alert and redirects to the current path' do
expect(flash[:alert]).to eq "Transfer failed: namespace directory cannot be moved"
end
it 'redirects to the current path' do
expect(response).to redirect_to(edit_group_path(group))
end
end
context 'when the user is not allowed to transfer the group' do
let(:new_parent_group) { create(:group, :public) }
let(:group) { create(:group, :public) }
let!(:group_member) { create(:group_member, :guest, group: group, user: user) }
let!(:new_parent_group_member) { create(:group_member, :guest, group: new_parent_group, user: user) }
@ -879,6 +862,7 @@ RSpec.describe GroupsController do
context 'transferring when a project has container images' do
let(:group) { create(:group, :public, :nested) }
let(:project) { create(:project, namespace: group) }
let!(:group_member) { create(:group_member, :owner, group: group, user: user) }
before do
@ -979,6 +963,8 @@ RSpec.describe GroupsController do
end
context 'when there is no file available to download' do
let(:admin) { create(:admin) }
before do
sign_in(admin)
end
@ -1149,9 +1135,7 @@ RSpec.describe GroupsController do
describe "GET #activity as JSON" do
include DesignManagementTestHelpers
render_views
let(:project) { create(:project, :public, group: group) }
let(:other_project) { create(:project, :public, group: group) }
def get_activity

View File

@ -9,7 +9,7 @@ FactoryBot.define do
content { 'Content for wiki page' }
format { :markdown }
message { nil }
project { association(:project, :wiki_repo) }
project { association(:project) }
container { project }
wiki { association(:wiki, container: container) }
page { OpenStruct.new(url_path: title) }
@ -18,6 +18,7 @@ FactoryBot.define do
initialize_with do
new(wiki, page).tap do |page|
page.attributes = {
slug: title&.tr(' ', '-'),
title: title,
content: content,
format: format

View File

@ -3,7 +3,7 @@
FactoryBot.define do
factory :wiki do
transient do
container { association(:project, :wiki_repo) }
container { association(:project) }
user { association(:user) }
end
@ -12,7 +12,7 @@ FactoryBot.define do
factory :project_wiki do
transient do
project { association(:project, :wiki_repo) }
project { association(:project) }
end
container { project }

View File

@ -6,7 +6,9 @@ RSpec.describe 'GFM autocomplete', :js do
let_it_be(:user_xss_title) { 'eve <img src=x onerror=alert(2)&lt;img src=x onerror=alert(1)&gt;' }
let_it_be(:user_xss) { create(:user, name: user_xss_title, username: 'xss.user') }
let_it_be(:user) { create(:user, name: '💃speciąl someone💃', username: 'someone.special') }
let_it_be(:project) { create(:project) }
let_it_be(:group) { create(:group, name: 'Ancestor') }
let_it_be(:child_group) { create(:group, parent: group, name: 'My group') }
let_it_be(:project) { create(:project, group: child_group) }
let_it_be(:label) { create(:label, project: project, title: 'special+') }
let(:issue) { create(:issue, project: project) }
@ -535,7 +537,7 @@ RSpec.describe 'GFM autocomplete', :js do
expect(page).to have_selector('.tribute-container', visible: true)
expect(find('.tribute-container ul', visible: true).text).to have_content(user_xss.username)
expect(find('.tribute-container ul', visible: true)).to have_text(user_xss.username)
end
it 'selects the first item for assignee dropdowns' do
@ -563,6 +565,24 @@ RSpec.describe 'GFM autocomplete', :js do
expect(find('.tribute-container ul', visible: true)).to have_content(user.name)
end
context 'when autocompleting for groups' do
it 'shows the group when searching for the name of the group' do
page.within '.timeline-content-form' do
find('#note-body').native.send_keys('@mygroup')
end
expect(find('.tribute-container ul', visible: true)).to have_text('My group')
end
it 'does not show the group when searching for the name of the parent of the group' do
page.within '.timeline-content-form' do
find('#note-body').native.send_keys('@ancestor')
end
expect(find('.tribute-container ul', visible: true)).not_to have_text('My group')
end
end
context 'if a selected value has special characters' do
it 'wraps the result in double quotes' do
note = find('#note-body')

View File

@ -26,10 +26,12 @@ RSpec.describe 'a maintainer edits files on a source-branch of an MR from a fork
visit project_merge_request_path(target_project, merge_request)
click_link 'Changes'
wait_for_requests
within first('.js-file-title') do
find('[data-testid="edit_file"]').click
click_link 'Edit in single-file editor'
page.within(first('.js-file-title')) do
find('.js-diff-more-actions').click
find('.js-edit-blob').click
end
wait_for_requests
end

View File

@ -34,7 +34,8 @@ RSpec.describe 'User comments on a diff', :js do
page.within('.diff-files-holder > div:nth-child(3)') do
expect(page).to have_content('Line is wrong')
find('.js-btn-vue-toggle-comments').click
find('.js-diff-more-actions').click
click_button 'Hide comments on this file'
expect(page).not_to have_content('Line is wrong')
end
@ -67,7 +68,8 @@ RSpec.describe 'User comments on a diff', :js do
# Hide the comment.
page.within('.diff-files-holder > div:nth-child(3)') do
find('.js-btn-vue-toggle-comments').click
find('.js-diff-more-actions').click
click_button 'Hide comments on this file'
expect(page).not_to have_content('Line is wrong')
end
@ -80,7 +82,8 @@ RSpec.describe 'User comments on a diff', :js do
# Show the comment.
page.within('.diff-files-holder > div:nth-child(3)') do
find('.js-btn-vue-toggle-comments').click
find('.js-diff-more-actions').click
click_button 'Show comments on this file'
end
# Now both the comments should be shown.

View File

@ -63,7 +63,7 @@ RSpec.describe 'Merge request > User sees diff', :js do
visit diffs_project_merge_request_path(project, merge_request)
# Throws `Capybara::Poltergeist::InvalidSelector` if we try to use `#hash` syntax
expect(page).to have_selector("[id=\"#{changelog_id}\"] [data-testid='edit_file']")
expect(page).to have_selector("[id=\"#{changelog_id}\"] .js-edit-blob", visible: false)
end
end
@ -73,7 +73,8 @@ RSpec.describe 'Merge request > User sees diff', :js do
visit diffs_project_merge_request_path(project, merge_request)
# Throws `Capybara::Poltergeist::InvalidSelector` if we try to use `#hash` syntax
find("[id=\"#{changelog_id}\"] [data-testid=\"edit_file\"").click
find("[id=\"#{changelog_id}\"] .js-diff-more-actions").click
find("[id=\"#{changelog_id}\"] .js-edit-blob").click
expect(page).to have_selector('.js-fork-suggestion-button', count: 1)
expect(page).to have_selector('.js-cancel-fork-suggestion-button', count: 1)

View File

@ -119,7 +119,8 @@ RSpec.describe 'User comments on a diff', :js do
it 'can add and remove suggestions from a batch' do
files.each_with_index do |file, index|
page.within("[id='#{file[:hash]}']") do
find("button[title='Show full file']").click
find('.js-diff-more-actions').click
click_button 'Show full file'
wait_for_requests
click_diff_line(find("[id='#{file[:line_code]}']"))

View File

@ -20,22 +20,14 @@ RSpec.describe 'Editing file blob', :js do
sign_in(user)
end
def edit_and_commit(commit_changes: true)
def edit_and_commit(commit_changes: true, is_diff: false)
wait_for_requests
find('.js-edit-blob').click
fill_and_commit(commit_changes)
end
if is_diff
first('.js-diff-more-actions').click
end
def mr_edit_and_commit(commit_changes: true)
wait_for_requests
find('[data-testid="edit_file"]').click
click_link 'Edit in single-file editor'
fill_and_commit(commit_changes)
end
def fill_and_commit(commit_changes)
first('.js-edit-blob').click
fill_editor(content: 'class NextFeature\\nend\\n')
if commit_changes
@ -51,7 +43,7 @@ RSpec.describe 'Editing file blob', :js do
context 'from MR diff' do
before do
visit diffs_project_merge_request_path(project, merge_request)
mr_edit_and_commit
edit_and_commit(is_diff: true)
end
it 'returns me to the mr' do

Binary file not shown.

Binary file not shown.

View File

@ -1152,4 +1152,44 @@ describe('Api', () => {
});
});
});
describe('trackRedisHllUserEvent', () => {
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/usage_data/increment_unique_users`;
const event = 'dummy_event';
const postData = { event };
const headers = {
'Content-Type': 'application/json',
};
describe('when usage data increment unique users is called with feature flag disabled', () => {
beforeEach(() => {
gon.features = { ...gon.features, usageDataApi: false };
});
it('returns null', () => {
jest.spyOn(axios, 'post');
mock.onPost(expectedUrl).replyOnce(httpStatus.OK, true);
expect(axios.post).toHaveBeenCalledTimes(0);
expect(Api.trackRedisHllUserEvent(event)).toEqual(null);
});
});
describe('when usage data increment unique users is called', () => {
beforeEach(() => {
gon.features = { ...gon.features, usageDataApi: true };
});
it('resolves the Promise', () => {
jest.spyOn(axios, 'post');
mock.onPost(expectedUrl, { event }).replyOnce(httpStatus.OK, true);
return Api.trackRedisHllUserEvent(event).then(({ data }) => {
expect(data).toEqual(true);
expect(axios.post).toHaveBeenCalledWith(expectedUrl, postData, { headers });
});
});
});
});
});

View File

@ -1,9 +1,7 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlIcon } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
import EditButton from '~/diffs/components/edit_button.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import diffDiscussionsMockData from '../mock_data/diff_discussions';
import { truncateSha } from '~/lib/utils/text_utility';
@ -76,16 +74,7 @@ describe('DiffFileHeader component', () => {
const findReplacedFileButton = () => wrapper.find({ ref: 'replacedFileButton' });
const findViewFileButton = () => wrapper.find({ ref: 'viewButton' });
const findCollapseIcon = () => wrapper.find({ ref: 'collapseIcon' });
const hasZDropdownMenuClass = () => wrapper.classes('gl-z-dropdown-menu!');
const findIconByName = iconName => {
const icons = wrapper.findAll(GlIcon).filter(w => w.props('name') === iconName);
if (icons.length === 0) return icons;
if (icons.length > 1) {
throw new Error(`Multiple icons found for ${iconName}`);
}
return icons.at(0);
};
const findEditButton = () => wrapper.find({ ref: 'editButton' });
const createComponent = props => {
mockStoreConfig = cloneDeep(defaultMockStoreConfig);
@ -152,10 +141,6 @@ describe('DiffFileHeader component', () => {
expect(wrapper.find(ClipboardButton).exists()).toBe(true);
});
it('should not have z dropdown menu class', () => {
expect(hasZDropdownMenuClass()).toBe(false);
});
describe('for submodule', () => {
const submoduleDiffFile = {
...diffFile,
@ -208,16 +193,6 @@ describe('DiffFileHeader component', () => {
describe('for any file', () => {
const otherModes = Object.keys(diffViewerModes).filter(m => m !== 'mode_changed');
it('when edit button emits showForkMessage event it is re-emitted', () => {
createComponent({
addMergeRequestButtons: true,
});
wrapper.find(EditButton).vm.$emit('showForkMessage');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted().showForkMessage).toBeDefined();
});
});
it('for mode_changed file mode displays mode changes', () => {
createComponent({
diffFile: {
@ -276,16 +251,16 @@ describe('DiffFileHeader component', () => {
});
it('should not render edit button', () => {
createComponent({ addMergeRequestButtons: false });
expect(wrapper.find(EditButton).exists()).toBe(false);
expect(findEditButton().exists()).toBe(false);
});
});
describe('when addMergeRequestButtons is true', () => {
describe('without discussions', () => {
it('renders a disabled toggle discussions button', () => {
it('does not render a toggle discussions button', () => {
diffHasDiscussionsResultMock.mockReturnValue(false);
createComponent({ addMergeRequestButtons: true });
expect(findToggleDiscussionsButton().attributes('disabled')).toBe('true');
expect(findToggleDiscussionsButton().exists()).toBe(false);
});
});
@ -293,7 +268,7 @@ describe('DiffFileHeader component', () => {
it('dispatches toggleFileDiscussionWrappers when user clicks on toggle discussions button', () => {
diffHasDiscussionsResultMock.mockReturnValue(true);
createComponent({ addMergeRequestButtons: true });
expect(findToggleDiscussionsButton().attributes('disabled')).toBeFalsy();
expect(findToggleDiscussionsButton().exists()).toBe(true);
findToggleDiscussionsButton().vm.$emit('click');
expect(
mockStoreConfig.modules.diffs.actions.toggleFileDiscussionWrappers,
@ -305,28 +280,7 @@ describe('DiffFileHeader component', () => {
createComponent({
addMergeRequestButtons: true,
});
expect(wrapper.find(EditButton).exists()).toBe(true);
});
describe('when edit button opens', () => {
beforeEach(async () => {
createComponent({ addMergeRequestButtons: true });
wrapper.find(EditButton).vm.$emit('open');
await wrapper.vm.$nextTick();
});
it('should add z dropdown menu class when edit button opens', async () => {
expect(hasZDropdownMenuClass()).toBe(true);
});
it('when closes again, should remove class', async () => {
wrapper.find(EditButton).vm.$emit('close');
await wrapper.vm.$nextTick();
expect(hasZDropdownMenuClass()).toBe(false);
});
expect(findEditButton().exists()).toBe(true);
});
describe('view on environment button', () => {
@ -360,7 +314,7 @@ describe('DiffFileHeader component', () => {
});
it('should not render edit button', () => {
expect(wrapper.find(EditButton).exists()).toBe(false);
expect(findEditButton().exists()).toBe(false);
});
});
describe('with file blob', () => {
@ -371,7 +325,7 @@ describe('DiffFileHeader component', () => {
addMergeRequestButtons: true,
});
expect(findViewFileButton().attributes('href')).toBe(viewPath);
expect(findViewFileButton().attributes('title')).toEqual(
expect(findViewFileButton().text()).toEqual(
`View file @ ${diffFile.content_sha.substr(0, 8)}`,
);
});
@ -401,21 +355,6 @@ describe('DiffFileHeader component', () => {
addMergeRequestButtons: true,
};
it.each`
iconName | isShowingFullFile
${'doc-expand'} | ${false}
${'doc-changes'} | ${true}
`(
'shows $iconName when isShowingFullFile set to $isShowingFullFile',
({ iconName, isShowingFullFile }) => {
createComponent({
...fullyNotExpandedFileProps,
diffFile: { ...fullyNotExpandedFileProps.diffFile, isShowingFullFile },
});
expect(findIconByName(iconName).exists()).toBe(true);
},
);
it('renders expand to full file button if not showing full file already', () => {
createComponent(fullyNotExpandedFileProps);
expect(findExpandButton().exists()).toBe(true);
@ -481,7 +420,7 @@ describe('DiffFileHeader component', () => {
it('does not show edit button', () => {
createComponent({ diffFile: { ...diffFile, deleted_file: true } });
expect(wrapper.find(EditButton).exists()).toBe(false);
expect(findEditButton().exists()).toBe(false);
});
});

View File

@ -1,140 +0,0 @@
import { shallowMount, mount } from '@vue/test-utils';
import { GlDeprecatedDropdown, GlDeprecatedDropdownItem, GlIcon } from '@gitlab/ui';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import EditButton from '~/diffs/components/edit_button.vue';
jest.mock('lodash/uniqueId', () => (str = '') => `${str}fake`);
const TOOLTIP_ID = 'edit_button_tooltip_fake';
const EDIT_ITEM = {
href: 'test-path',
text: 'Edit in single-file editor',
};
const IDE_EDIT_ITEM = {
href: 'ide-test-path',
text: 'Edit in Web IDE',
};
describe('EditButton', () => {
let wrapper;
const createComponent = (props = {}, mountFn = shallowMount) => {
wrapper = mountFn(EditButton, {
propsData: {
editPath: EDIT_ITEM.href,
ideEditPath: IDE_EDIT_ITEM.href,
canCurrentUserFork: false,
...props,
},
directives: {
GlTooltip: createMockDirective(),
},
});
};
afterEach(() => {
wrapper.destroy();
});
const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value;
const findDropdown = () => wrapper.find(GlDeprecatedDropdown);
const parseDropdownItems = () =>
wrapper.findAll(GlDeprecatedDropdownItem).wrappers.map(x => ({
text: x.text(),
href: x.attributes('href'),
}));
const triggerShow = () => {
const event = new Event('');
jest.spyOn(event, 'preventDefault');
findDropdown().vm.$emit('show', event);
return event;
};
it.each`
props | expectedItems
${{}} | ${[EDIT_ITEM, IDE_EDIT_ITEM]}
${{ editPath: '' }} | ${[IDE_EDIT_ITEM]}
${{ ideEditPath: '' }} | ${[EDIT_ITEM]}
`('should render items with=$props', ({ props, expectedItems }) => {
createComponent(props);
expect(parseDropdownItems()).toEqual(expectedItems);
});
describe('with default', () => {
beforeEach(() => {
createComponent({}, mount);
});
it('does not have tooltip', () => {
expect(getTooltip()).toEqual({ id: TOOLTIP_ID, title: 'Edit file in...' });
});
it('shows pencil dropdown', () => {
expect(wrapper.find(GlIcon).props('name')).toBe('pencil');
expect(wrapper.find('.gl-dropdown-caret').exists()).toBe(true);
});
describe.each`
event | expectedEmit | expectedRootEmit
${'show'} | ${'open'} | ${[['bv::hide::tooltip', TOOLTIP_ID]]}
${'hide'} | ${'close'} | ${[]}
`('when dropdown emits $event', ({ event, expectedEmit, expectedRootEmit }) => {
let rootEmitSpy;
beforeEach(() => {
rootEmitSpy = jest.spyOn(wrapper.vm.$root, '$emit');
findDropdown().vm.$emit(event);
});
it(`emits ${expectedEmit}`, () => {
expect(wrapper.emitted(expectedEmit)).toEqual([[]]);
});
it(`emits root = ${JSON.stringify(expectedRootEmit)}`, () => {
expect(rootEmitSpy.mock.calls).toEqual(expectedRootEmit);
});
});
});
describe('with cant modify blob and can fork', () => {
beforeEach(() => {
createComponent({
canModifyBlob: false,
canCurrentUserFork: true,
});
});
it('when try to open, emits showForkMessage', () => {
expect(wrapper.emitted('showForkMessage')).toBeUndefined();
const event = triggerShow();
expect(wrapper.emitted('showForkMessage')).toEqual([[]]);
expect(event.preventDefault).toHaveBeenCalled();
expect(wrapper.emitted('open')).toBeUndefined();
});
});
describe('with editPath is falsey', () => {
beforeEach(() => {
createComponent({
editPath: '',
});
});
it('should disable dropdown', () => {
expect(findDropdown().attributes('disabled')).toBe('true');
});
it('should have tooltip', () => {
expect(getTooltip()).toEqual({
id: TOOLTIP_ID,
title: "Can't edit as source branch was deleted",
});
});
});
});

View File

@ -139,50 +139,74 @@ describe('Diffs Module Getters', () => {
describe('diffHasExpandedDiscussions', () => {
it('returns true when one of the discussions is expanded', () => {
discussionMock1.expanded = false;
const diffFile = {
parallel_diff_lines: [],
highlighted_diff_lines: [
{
discussions: [discussionMock, discussionMock],
discussionsExpanded: true,
},
],
};
expect(
getters.diffHasExpandedDiscussions(localState, {
getDiffFileDiscussions: () => [discussionMock, discussionMock],
})(diffFileMock),
).toEqual(true);
expect(getters.diffHasExpandedDiscussions(localState)(diffFile)).toEqual(true);
});
it('returns false when there are no discussions', () => {
expect(
getters.diffHasExpandedDiscussions(localState, { getDiffFileDiscussions: () => [] })(
diffFileMock,
),
).toEqual(false);
const diffFile = {
parallel_diff_lines: [],
highlighted_diff_lines: [
{
discussions: [],
discussionsExpanded: true,
},
],
};
expect(getters.diffHasExpandedDiscussions(localState)(diffFile)).toEqual(false);
});
it('returns false when no discussion is expanded', () => {
discussionMock.expanded = false;
discussionMock1.expanded = false;
const diffFile = {
parallel_diff_lines: [],
highlighted_diff_lines: [
{
discussions: [discussionMock, discussionMock],
discussionsExpanded: false,
},
],
};
expect(
getters.diffHasExpandedDiscussions(localState, {
getDiffFileDiscussions: () => [discussionMock, discussionMock1],
})(diffFileMock),
).toEqual(false);
expect(getters.diffHasExpandedDiscussions(localState)(diffFile)).toEqual(false);
});
});
describe('diffHasDiscussions', () => {
it('returns true when getDiffFileDiscussions returns discussions', () => {
expect(
getters.diffHasDiscussions(localState, {
getDiffFileDiscussions: () => [discussionMock],
})(diffFileMock),
).toEqual(true);
const diffFile = {
parallel_diff_lines: [],
highlighted_diff_lines: [
{
discussions: [discussionMock, discussionMock],
discussionsExpanded: false,
},
],
};
expect(getters.diffHasDiscussions(localState)(diffFile)).toEqual(true);
});
it('returns false when getDiffFileDiscussions returns no discussions', () => {
expect(
getters.diffHasDiscussions(localState, {
getDiffFileDiscussions: () => [],
})(diffFileMock),
).toEqual(false);
const diffFile = {
parallel_diff_lines: [],
highlighted_diff_lines: [
{
discussions: [],
discussionsExpanded: false,
},
],
};
expect(getters.diffHasDiscussions(localState)(diffFile)).toEqual(false);
});
});

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