Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2025-06-04 15:12:20 +00:00
parent d58a3afef8
commit eb7e5e27af
79 changed files with 920 additions and 1538 deletions

View File

@ -16,7 +16,7 @@ variables:
# Retry failed specs in separate process
QA_RETRY_FAILED_SPECS: "true"
# Helm chart ref used by test-on-cng pipeline
GITLAB_HELM_CHART_REF: "67dde1999693aa85eb97bebdb6ba0177814e9c6c"
GITLAB_HELM_CHART_REF: "26a3a2715a90b3a5d9cc01fff87dac0c27c42c49"
# Specific ref for cng-mirror project to trigger builds for
GITLAB_CNG_MIRROR_REF: "df7aafcccafdbab732a7cf757efb3b7b74c851dd"
# Makes sure some of the common scripts from pipeline-common use bundler to execute commands

View File

@ -2595,10 +2595,8 @@ Layout/LineLength:
- 'spec/features/profiles/user_edit_profile_spec.rb'
- 'spec/features/projects/artifacts/file_spec.rb'
- 'spec/features/projects/artifacts/user_downloads_artifacts_spec.rb'
- 'spec/features/projects/blobs/blob_line_permalink_updater_spec.rb'
- 'spec/features/projects/blobs/blob_show_spec.rb'
- 'spec/features/projects/blobs/edit_spec.rb'
- 'spec/features/projects/blobs/shortcuts_blob_spec.rb'
- 'spec/features/projects/blobs/user_views_pipeline_editor_button_spec.rb'
- 'spec/features/projects/ci/editor_spec.rb'
- 'spec/features/projects/commit/cherry_pick_spec.rb'

View File

@ -390,12 +390,9 @@ export const PROJECT_FILES_GO_BACK = {
overrideGlobalHotkey: true,
};
const { blobOverflowMenu } = gon.features ?? {};
export const PROJECT_FILES_GO_TO_PERMALINK = {
id: 'projectFiles.goToFilePermalink',
description: blobOverflowMenu
? __('Copy file permalink')
: __('Go to file permalink (while viewing a file)'),
export const PROJECT_FILES_COPY_FILE_PERMALINK = {
id: 'projectFiles.copyFilePermalink',
description: __('Copy file permalink'),
defaultKeys: ['y'],
};
@ -681,7 +678,7 @@ const PROJECT_FILES_SHORTCUTS_GROUP = {
PROJECT_FILES_MOVE_SELECTION_DOWN,
PROJECT_FILES_OPEN_SELECTION,
PROJECT_FILES_GO_BACK,
PROJECT_FILES_GO_TO_PERMALINK,
PROJECT_FILES_COPY_FILE_PERMALINK,
PROJECT_FILES_GO_TO_COMPARE,
],
};

View File

@ -1,14 +0,0 @@
import { PROJECT_FILES_GO_TO_PERMALINK } from '~/behaviors/shortcuts/keybindings';
import { moveToFilePermalink } from '~/blob/utils';
export default class ShortcutsBlob {
constructor(shortcuts) {
const { blobOverflowMenu } = gon.features ?? {};
if (blobOverflowMenu) {
// TODO: Remove ShortcutsBlob entirely once these feature flags are removed.
return;
}
shortcuts.add(PROJECT_FILES_GO_TO_PERMALINK, moveToFilePermalink);
}
}

View File

@ -1,45 +0,0 @@
import { getLocationHash } from '~/lib/utils/url_utility';
import { getPageParamValue, getPageSearchString } from './utils';
const lineNumberRe = /^(L|LC)[0-9]+/;
const updateLineNumbersOnBlobPermalinks = (linksToUpdate) => {
const hash = getLocationHash();
if (hash && lineNumberRe.test(hash)) {
const hashUrlString = `#${hash}`;
[].concat(Array.prototype.slice.call(linksToUpdate)).forEach((permalinkButton) => {
const baseHref =
permalinkButton.dataset.originalHref ||
(() => {
const href = permalinkButton.getAttribute('href');
// eslint-disable-next-line no-param-reassign
permalinkButton.dataset.originalHref = href;
return href;
})();
const lineNum = parseInt(hash.split('L')[1], 10);
const page = getPageParamValue(lineNum);
const searchString = getPageSearchString(baseHref, page);
permalinkButton.setAttribute('href', `${baseHref}${searchString}${hashUrlString}`);
});
}
};
function BlobLinePermalinkUpdater(blobContentHolder, lineNumberSelector, elementsToUpdate) {
if (!blobContentHolder) return;
const updateBlameAndBlobPermalinkCb = () => {
// Wait for the hash to update from the LineHighlighter callback
setTimeout(() => {
updateLineNumbersOnBlobPermalinks(elementsToUpdate);
}, 0);
};
blobContentHolder.addEventListener('click', (e) => {
if (e.target.matches(lineNumberSelector)) {
updateBlameAndBlobPermalinkCb();
}
});
updateBlameAndBlobPermalinkCb();
}
export default BlobLinePermalinkUpdater;

View File

@ -1,9 +1,5 @@
<script>
import DefaultActions from 'jh_else_ce/blob/components/blob_header_default_actions.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import userInfoQuery from '../queries/user_info.query.graphql';
import applicationInfoQuery from '../queries/application_info.query.graphql';
import BlobFilepath from './blob_header_filepath.vue';
import ViewerSwitcher from './blob_header_viewer_switcher.vue';
import { SIMPLE_BLOB_VIEWER, BLAME_VIEWER } from './constants';
@ -15,23 +11,6 @@ export default {
DefaultActions,
BlobFilepath,
TableOfContents,
WebIdeLink: () => import('ee_else_ce/vue_shared/components/web_ide_link.vue'),
},
mixins: [glFeatureFlagMixin()],
apollo: {
// eslint-disable-next-line @gitlab/vue-no-undef-apollo-properties
currentUser: {
query: userInfoQuery,
error() {
this.$emit('error');
},
},
gitpodEnabled: {
query: applicationInfoQuery,
error() {
this.$emit('error');
},
},
},
props: {
blob: {
@ -73,21 +52,8 @@ export default {
required: false,
default: false,
},
showForkSuggestion: {
type: Boolean,
required: false,
default: false,
},
showWebIdeForkSuggestion: {
type: Boolean,
required: false,
default: false,
},
projectPath: {
type: String,
required: false,
default: '',
},
// Used in EE component
// eslint-disable-next-line vue/no-unused-properties
projectId: {
type: String,
required: false,
@ -103,36 +69,16 @@ export default {
required: false,
default: true,
},
editButtonVariant: {
type: String,
required: false,
default: 'confirm',
},
isUsingLfs: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
viewer: this.hideViewerSwitcher ? null : this.activeViewerType,
gitpodEnabled: false,
};
},
computed: {
showWebIdeLink() {
return !this.blob.archived && this.blob.editBlobPath;
},
isEmpty() {
return this.blob.rawSize === '0';
},
blobSwitcherDocIcon() {
return this.blob.richViewer?.fileType === 'csv' ? 'table' : 'document';
},
projectIdAsNumber() {
return getIdFromGraphQLId(this.projectId);
},
},
watch: {
viewer(newVal, oldVal) {
@ -151,8 +97,6 @@ export default {
<template>
<div class="js-file-title file-title-flex-parent">
<div class="gl-mb-3 gl-flex gl-gap-3 md:gl-mb-0">
<table-of-contents v-if="!glFeatures.blobOverflowMenu" class="gl-pr-2" />
<blob-filepath
:blob="blob"
:show-path="showPath"
@ -169,37 +113,12 @@ export default {
<viewer-switcher
v-if="!hideViewerSwitcher"
v-model="viewer"
:doc-icon="blobSwitcherDocIcon"
:show-blame-toggle="showBlameToggle"
:show-viewer-toggles="Boolean(blob.simpleViewer && blob.richViewer)"
v-on="$listeners"
/>
<table-of-contents v-if="glFeatures.blobOverflowMenu" class="gl-pr-2" />
<table-of-contents class="gl-pr-2" />
<slot name="ee-duo-workflow-action" data-test-id="ee-duo-workflow-action"></slot>
<web-ide-link
v-if="!glFeatures.blobOverflowMenu && showWebIdeLink"
:disabled="isUsingLfs"
:show-edit-button="!isBinary"
:button-variant="editButtonVariant"
class="sm:!gl-ml-0"
:edit-url="blob.editBlobPath"
:web-ide-url="blob.ideEditPath"
:needs-to-fork="showForkSuggestion"
:needs-to-fork-with-web-ide="showWebIdeForkSuggestion"
:show-pipeline-editor-button="Boolean(blob.pipelineEditorPath)"
:pipeline-editor-url="blob.pipelineEditorPath"
:gitpod-url="blob.gitpodBlobUrl"
:is-gitpod-enabled-for-instance="gitpodEnabled"
:is-gitpod-enabled-for-user="currentUser && currentUser.gitpodEnabled"
:project-path="projectPath"
:project-id="projectIdAsNumber"
:user-preferences-gitpod-path="currentUser && currentUser.preferencesGitpodPath"
:user-profile-enable-gitpod-path="currentUser && currentUser.profileEnableGitpodPath"
is-blob
disable-fork-modal
v-on="$listeners"
/>
<slot name="actions"></slot>

View File

@ -2,7 +2,6 @@
import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import { setUrlParams, relativePathToAbsolute, getBaseURL } from '~/lib/utils/url_utility';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
BTN_COPY_CONTENTS_TITLE,
BTN_DOWNLOAD_TITLE,
@ -19,7 +18,6 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagMixin()],
inject: {
blobHash: {
default: '',
@ -105,10 +103,7 @@ export default {
};
</script>
<template>
<gl-button-group
:class="{ 'gl-hidden sm:gl-inline-flex': glFeatures.blobOverflowMenu }"
data-testid="default-actions-container"
>
<gl-button-group class="gl-hidden sm:gl-inline-flex" data-testid="default-actions-container">
<gl-button
v-if="!isEmpty && showCopyButton"
v-gl-tooltip.hover

View File

@ -1,7 +1,6 @@
<script>
import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui';
import { InternalEvents } from '~/tracking';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
RICH_BLOB_VIEWER,
RICH_BLOB_VIEWER_TITLE,
@ -21,18 +20,13 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [InternalEvents.mixin(), glFeatureFlagMixin()],
mixins: [InternalEvents.mixin()],
props: {
value: {
type: String,
default: SIMPLE_BLOB_VIEWER,
required: false,
},
docIcon: {
type: String,
default: 'document',
required: false,
},
showViewerToggles: {
type: Boolean,
required: false,
@ -86,13 +80,12 @@ export default {
:title="$options.SIMPLE_BLOB_VIEWER_TITLE"
:selected="isSimpleViewer"
data-testid="simple-blob-viewer-button"
:icon="glFeatures.blobOverflowMenu ? '' : 'code'"
category="primary"
variant="default"
class="js-blob-viewer-switch-btn"
data-viewer="simple"
@click="switchToViewer($options.SIMPLE_BLOB_VIEWER)"
>{{ glFeatures.blobOverflowMenu ? $options.SIMPLE_BLOB_VIEWER_LABEL : '' }}</gl-button
>{{ $options.SIMPLE_BLOB_VIEWER_LABEL }}</gl-button
>
<gl-button
v-if="showViewerToggles"
@ -100,14 +93,14 @@ export default {
:aria-label="$options.RICH_BLOB_VIEWER_TITLE"
:title="$options.RICH_BLOB_VIEWER_TITLE"
:selected="isRichViewer"
:icon="glFeatures.blobOverflowMenu ? '' : docIcon"
icon=""
data-testid="rich-blob-viewer-button"
category="primary"
variant="default"
class="js-blob-viewer-switch-btn"
data-viewer="rich"
@click="switchToViewer($options.RICH_BLOB_VIEWER)"
>{{ glFeatures.blobOverflowMenu ? $options.RICH_BLOB_VIEWER_LABEL : '' }}</gl-button
>{{ $options.RICH_BLOB_VIEWER_LABEL }}</gl-button
>
<gl-button
v-if="showBlameToggle"

View File

@ -1,8 +0,0 @@
query getUserInfo {
currentUser {
id
gitpodEnabled
preferencesGitpodPath
profileEnableGitpodPath
}
}

View File

@ -112,7 +112,14 @@ export default {
class="js-user-link gl-relative"
data-testid="assigned-user"
>
<gl-avatar :src="user.avatarUrl" :size="32" class="!gl-bg-subtle" />
<gl-avatar
:src="user.avatarUrl"
:size="32"
width="32"
height="32"
class="!gl-bg-subtle"
loading="lazy"
/>
<span
v-if="reviewStateIcon(user)"
class="gl-absolute -gl-bottom-2 -gl-left-2 gl-flex gl-h-5 gl-w-5 gl-items-center gl-justify-center gl-rounded-full gl-p-1"

View File

@ -8,14 +8,4 @@ initNewResourceDropdown({
query: searchUserProjectsWithMergeRequestsEnabled,
});
const el = document.getElementById('js-merge-request-dashboard');
if (el) {
requestIdleCallback(async () => {
const { initMergeRequestDashboard } = await import('~/merge_request_dashboard');
initMergeRequestDashboard(el);
});
} else {
initMergeRequestsDashboard();
}
initMergeRequestsDashboard();

View File

@ -0,0 +1,16 @@
import { RESOURCE_TYPE_MERGE_REQUEST } from '~/vue_shared/components/new_resource_dropdown/constants';
import searchUserProjectsWithMergeRequestsEnabled from '~/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_merge_requests_enabled.query.graphql';
import { initMergeRequestDashboard } from '~/merge_request_dashboard';
initMergeRequestDashboard(document.getElementById('js-merge-request-dashboard'));
requestIdleCallback(async () => {
const { initNewResourceDropdown } = await import(
'~/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown'
);
initNewResourceDropdown({
resourceType: RESOURCE_TYPE_MERGE_REQUEST,
query: searchUserProjectsWithMergeRequestsEnabled,
});
});

View File

@ -1,25 +1,15 @@
import { addShortcutsExtension } from '~/behaviors/shortcuts';
import ShortcutsBlob from '~/behaviors/shortcuts/shortcuts_blob';
import { shortcircuitPermalinkButton } from '~/blob/utils';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import BlobForkSuggestion from '~/blob/blob_fork_suggestion';
import BlobLinePermalinkUpdater from '~/blob/blob_line_permalink_updater';
import LineHighlighter from '~/blob/line_highlighter';
export default () => {
new LineHighlighter(); // eslint-disable-line no-new
// eslint-disable-next-line no-new
new BlobLinePermalinkUpdater(
document.querySelector('#blob-content-holder'),
'.file-line-num[data-line-number], .file-line-num[data-line-number] *',
document.querySelectorAll('.js-data-file-blob-permalink-url, .js-blob-blame-link'),
);
shortcircuitPermalinkButton();
addShortcutsExtension(ShortcutsNavigation);
addShortcutsExtension(ShortcutsBlob);
new BlobForkSuggestion({
openButtons: document.querySelectorAll('.js-edit-blob-link-fork-toggler'),

View File

@ -1,155 +0,0 @@
<script>
import { GlButtonGroup, GlButton } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { sprintf, __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getRefMixin from '../mixins/get_ref';
import DeleteBlobModal from './delete_blob_modal.vue';
import UploadBlobModal from './upload_blob_modal.vue';
const REPLACE_BLOB_MODAL_ID = 'modal-replace-blob';
export default {
i18n: {
replace: __('Replace'),
delete: __('Delete'),
},
components: {
GlButtonGroup,
GlButton,
UploadBlobModal,
DeleteBlobModal,
LockFileButton: () => import('ee_component/repository/components/lock_file_button.vue'),
},
mixins: [getRefMixin, glFeatureFlagMixin()],
inject: {
targetBranch: {
default: '',
},
originalBranch: {
default: '',
},
},
props: {
name: {
type: String,
required: true,
},
path: {
type: String,
required: true,
},
replacePath: {
type: String,
required: true,
},
deletePath: {
type: String,
required: true,
},
canPushCode: {
type: Boolean,
required: true,
},
canPushToBranch: {
type: Boolean,
required: true,
},
emptyRepo: {
type: Boolean,
required: true,
},
projectPath: {
type: String,
required: true,
},
isLocked: {
type: Boolean,
required: true,
},
canLock: {
type: Boolean,
required: true,
},
showForkSuggestion: {
type: Boolean,
required: true,
},
isUsingLfs: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
deleteModalId() {
return uniqueId('delete-modal');
},
replaceCommitMessage() {
return sprintf(__('Replace %{name}'), { name: this.name });
},
deleteModalCommitMessage() {
return sprintf(__('Delete %{name}'), { name: this.name });
},
lockBtnTestId() {
return this.canLock ? 'lock-button' : 'disabled-lock-button';
},
},
methods: {
showModal(modalId) {
if (this.showForkSuggestion) {
this.$emit('fork', 'view');
return;
}
this.$refs[modalId].show();
},
},
replaceBlobModalId: REPLACE_BLOB_MODAL_ID,
};
</script>
<template>
<div>
<gl-button-group>
<lock-file-button
v-if="glFeatures.fileLocks"
:name="name"
:path="path"
:project-path="projectPath"
:is-locked="isLocked"
:can-lock="canLock"
:data-testid="lockBtnTestId"
/>
<gl-button data-testid="replace" @click="showModal($options.replaceBlobModalId)">
{{ $options.i18n.replace }}
</gl-button>
<gl-button data-testid="delete" @click="showModal(deleteModalId)">
{{ $options.i18n.delete }}
</gl-button>
</gl-button-group>
<upload-blob-modal
:ref="$options.replaceBlobModalId"
:modal-id="$options.replaceBlobModalId"
:commit-message="replaceCommitMessage"
:target-branch="targetBranch || ref"
:original-branch="originalBranch || ref"
:can-push-code="canPushCode"
:can-push-to-branch="canPushToBranch"
:path="path"
:replace-path="replacePath"
/>
<delete-blob-modal
:ref="deleteModalId"
:delete-path="deletePath"
:modal-id="deleteModalId"
:commit-message="deleteModalCommitMessage"
:target-branch="targetBranch || ref"
:original-branch="originalBranch || ref"
:can-push-code="canPushCode"
:can-push-to-branch="canPushToBranch"
:empty-repo="emptyRepo"
:is-using-lfs="isUsingLfs"
/>
</div>
</template>

View File

@ -29,8 +29,6 @@ import {
EMPTY_FILE,
EVENT_FILE_SIZE_LIMIT_EXCEEDED,
} from '../constants';
import BlobButtonGroup from './blob_button_group.vue';
import ForkSuggestion from './fork_suggestion.vue';
import { loadViewer } from './blob_viewers';
const trackingMixin = InternalEvents.mixin();
@ -38,11 +36,9 @@ const trackingMixin = InternalEvents.mixin();
export default {
components: {
BlobHeader,
BlobButtonGroup,
BlobContent,
GlLoadingIcon,
GlButton,
ForkSuggestion,
CodeIntelligence,
AiGenie: () => import('ee_component/ai/components/ai_genie.vue'),
},
@ -408,30 +404,6 @@ export default {
@edit="editBlob"
@error="displayError"
@blame="handleToggleBlame"
>
<template #actions>
<blob-button-group
v-if="isLoggedIn && !blobInfo.archived && !glFeatures.blobOverflowMenu"
:path="path"
:name="blobInfo.name"
:replace-path="blobInfo.replacePath"
:delete-path="blobInfo.webPath"
:can-push-code="userPermissions.pushCode"
:can-push-to-branch="blobInfo.canCurrentUserPushToBranch"
:empty-repo="isEmptyRepository"
:project-path="projectPath"
:is-locked="Boolean(pathLockedByUser)"
:can-lock="canLock"
:show-fork-suggestion="showSingleFileEditorForkSuggestion"
:is-using-lfs="isUsingLfs"
@fork="setForkTarget('view')"
/>
</template>
</blob-header>
<fork-suggestion
v-if="forkTarget && showForkSuggestion"
:fork-path="forkPath"
@cancel="setForkTarget(null)"
/>
<blob-content
v-if="!blobViewer"

View File

@ -1,46 +0,0 @@
<script>
import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
i18n: {
message: __(
'You cant edit files directly in this project. Fork this project and submit a merge request with your changes.',
),
fork: __('Fork'),
cancel: __('Cancel'),
},
components: {
GlButton,
},
props: {
forkPath: {
type: String,
required: true,
},
},
};
</script>
<template>
<div
class="gl-flex gl-items-center gl-justify-end gl-border-1 gl-border-default gl-bg-subtle gl-px-5 gl-py-2 gl-border-b-solid"
>
<span class="gl-mr-6" data-testid="message">{{ $options.i18n.message }}</span>
<gl-button
class="gl-mr-3"
category="secondary"
variant="confirm"
data-method="post"
:href="forkPath"
data-testid="fork"
>
{{ $options.i18n.fork }}
</gl-button>
<gl-button data-testid="cancel" @click="$emit('cancel')">
{{ $options.i18n.cancel }}
</gl-button>
</div>
</template>

View File

@ -10,15 +10,12 @@ import getRefMixin from '~/repository/mixins/get_ref';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import initSourcegraph from '~/sourcegraph';
import Shortcuts from '~/behaviors/shortcuts/shortcuts';
import { addShortcutsExtension } from '~/behaviors/shortcuts';
import { shouldDisableShortcuts } from '~/behaviors/shortcuts/shortcuts_toggle';
import ShortcutsBlob from '~/behaviors/shortcuts/shortcuts_blob';
import { shortcircuitPermalinkButton } from '~/blob/utils';
import BlobLinePermalinkUpdater from '~/blob/blob_line_permalink_updater';
import {
keysFor,
START_SEARCH_PROJECT_FILE,
PROJECT_FILES_GO_TO_PERMALINK,
PROJECT_FILES_COPY_FILE_PERMALINK,
} from '~/behaviors/shortcuts/keybindings';
import { sanitize } from '~/lib/dompurify';
import { InternalEvents } from '~/tracking';
@ -32,7 +29,7 @@ import {
import { showBlameButton, isUsingLfs } from '~/repository/utils/storage_info_utils';
import blobControlsQuery from '~/repository/queries/blob_controls.query.graphql';
import userGitpodInfo from '~/repository/queries/user_gitpod_info.query.graphql';
import applicationInfoQuery from '~/blob/queries/application_info.query.graphql';
import applicationInfoQuery from '~/repository/queries/application_info.query.graphql';
import { getRefType } from '~/repository/utils/ref_type';
import OpenMrBadge from '~/repository/components/header_area/open_mr_badge.vue';
import BlobOverflowMenu from 'ee_else_ce/repository/components/header_area/blob_overflow_menu.vue';
@ -75,6 +72,7 @@ export default {
return !this.filePath;
},
error(error) {
this.hasProjectQueryErrors = true;
createAlert({ message: this.$options.i18n.errorMessage });
logError(
`Failed to fetch blob controls. See exception details for more information.`,
@ -139,6 +137,7 @@ export default {
currentUser: {},
gitpodEnabled: false,
isForkSuggestionModalVisible: false,
hasProjectQueryErrors: false,
};
},
computed: {
@ -151,6 +150,9 @@ export default {
blobInfo() {
return this.project?.repository?.blobs?.nodes[0] || {};
},
repository() {
return this.project?.repository || DEFAULT_BLOB_INFO.repository;
},
userPermissions() {
return this.project?.userPermissions || DEFAULT_BLOB_INFO.userPermissions;
},
@ -172,7 +174,7 @@ export default {
},
shortcuts() {
const findFileKey = keysFor(START_SEARCH_PROJECT_FILE)[0];
const permalinkKey = keysFor(PROJECT_FILES_GO_TO_PERMALINK)[0];
const permalinkKey = keysFor(PROJECT_FILES_COPY_FILE_PERMALINK)[0];
return {
findFile: findFileKey,
@ -220,7 +222,6 @@ export default {
initSourcegraph();
this.$nextTick(() => {
this.initShortcuts();
this.initLinksUpdate();
});
},
},
@ -230,15 +231,6 @@ export default {
},
initShortcuts() {
shortcircuitPermalinkButton();
addShortcutsExtension(ShortcutsBlob);
},
initLinksUpdate() {
// eslint-disable-next-line no-new
new BlobLinePermalinkUpdater(
document.querySelector('.tree-holder'),
'.file-line-num[data-line-number], .file-line-num[data-line-number] *',
document.querySelectorAll('.js-data-file-blob-permalink-url, .js-blob-blame-link'),
);
},
handleFindFile() {
this.trackEvent(FIND_FILE_BUTTON_CLICK);
@ -287,10 +279,7 @@ export default {
v-gl-tooltip.html="findFileTooltip"
:aria-keyshortcuts="findFileShortcutKey"
data-testid="find"
:class="[
$options.buttonClassList,
{ 'gl-hidden sm:gl-inline-flex': glFeatures.blobOverflowMenu },
]"
:class="[$options.buttonClassList, 'gl-hidden sm:gl-inline-flex']"
@click="handleFindFile"
>
{{ $options.i18n.findFile }}
@ -299,30 +288,15 @@ export default {
v-if="showBlameButton"
data-testid="blame"
:href="blobInfo.blamePath"
:class="[
$options.buttonClassList,
{ 'gl-hidden sm:gl-inline-flex': glFeatures.blobOverflowMenu },
]"
:class="[$options.buttonClassList, 'gl-hidden sm:gl-inline-flex']"
class="js-blob-blame-link"
@click="handleBlameClick"
>
{{ $options.i18n.blame }}
</gl-button>
<gl-button
v-if="!glFeatures.blobOverflowMenu"
v-gl-tooltip.html="permalinkTooltip"
:aria-keyshortcuts="permalinkShortcutKey"
data-testid="permalink"
:href="blobInfo.permalinkPath"
:class="$options.buttonClassList"
class="js-data-file-blob-permalink-url"
>
{{ $options.i18n.permalink }}
</gl-button>
<web-ide-link
v-if="glFeatures.blobOverflowMenu && showWebIdeLink"
v-if="showWebIdeLink && !hasProjectQueryErrors"
:show-edit-button="!isBinaryFileType"
class="!gl-m-0"
:edit-url="blobInfo.editBlobPath"
@ -344,7 +318,7 @@ export default {
@edit="onEdit"
/>
<fork-suggestion-modal
v-if="!isLoadingRepositoryBlob"
v-if="!isLoadingRepositoryBlob && !hasProjectQueryErrors"
:visible="isForkSuggestionModalVisible"
:fork-path="blobInfo.forkAndViewPath"
@hide="isForkSuggestionModalVisible = false"
@ -360,11 +334,11 @@ export default {
:inline="false"
/>
<blob-overflow-menu
v-if="!isLoadingRepositoryBlob && glFeatures.blobOverflowMenu"
v-if="!isLoadingRepositoryBlob && !hasProjectQueryErrors"
:project-path="projectPath"
:is-binary-file-type="isBinaryFileType"
:override-copy="true"
:is-empty-repository="project.repository.empty"
:is-empty-repository="repository.empty"
:is-using-lfs="isUsingLfs"
@copy="onCopy"
@showForkSuggestion="onShowForkSuggestion"

View File

@ -1,6 +1,7 @@
<script>
import { GlDisclosureDropdown, GlTooltipDirective } from '@gitlab/ui';
import { computed } from 'vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __ } from '~/locale';
import { createAlert } from '~/alert';
import { isLoggedIn } from '~/lib/utils/common_utils';
@ -31,6 +32,7 @@ export default {
directives: {
GlTooltipDirective,
},
mixins: [glFeatureFlagMixin()],
inject: ['blobInfo', 'currentRef'],
provide() {
return {
@ -65,22 +67,22 @@ export default {
eeCanModifyFile: {
type: Boolean,
required: false,
default: undefined,
default: false,
},
eeCanCreateLock: {
type: Boolean,
required: false,
default: undefined,
default: false,
},
eeCanDestroyLock: {
type: Boolean,
required: false,
default: undefined,
default: false,
},
eeIsLocked: {
type: Boolean,
required: false,
default: undefined,
default: false,
},
},
apollo: {
@ -120,10 +122,10 @@ export default {
return Boolean(this.viewer.renderError);
},
canModifyFile() {
return this.eeCanModifyFile !== undefined ? this.eeCanModifyFile : true;
return this.glFeatures.fileLocks ? this.eeCanModifyFile : true;
},
isLocked() {
return this.eeIsLocked !== undefined ? this.eeIsLocked : false;
return this.glFeatures.fileLocks ? this.eeIsLocked : false;
},
},
watch: {

View File

@ -3,7 +3,7 @@ import Vue from 'vue';
import { GlDisclosureDropdownItem, GlToast } from '@gitlab/ui';
import { __ } from '~/locale';
import { InternalEvents } from '~/tracking';
import { keysFor, PROJECT_FILES_GO_TO_PERMALINK } from '~/behaviors/shortcuts/keybindings';
import { keysFor, PROJECT_FILES_COPY_FILE_PERMALINK } from '~/behaviors/shortcuts/keybindings';
import { Mousetrap } from '~/lib/mousetrap';
import { shouldDisableShortcuts } from '~/behaviors/shortcuts/shortcuts_toggle';
import { hashState, updateHash } from '~/blob/state';
@ -34,7 +34,7 @@ export default {
},
computed: {
permalinkShortcutKey() {
return keysFor(PROJECT_FILES_GO_TO_PERMALINK)[0];
return keysFor(PROJECT_FILES_COPY_FILE_PERMALINK)[0];
},
shortcutsDisabled() {
return shouldDisableShortcuts();
@ -45,11 +45,11 @@ export default {
},
mounted() {
this.mousetrap = new Mousetrap();
this.mousetrap.bind(keysFor(PROJECT_FILES_GO_TO_PERMALINK), this.triggerCopyPermalink);
this.mousetrap.bind(keysFor(PROJECT_FILES_COPY_FILE_PERMALINK), this.triggerCopyPermalink);
window.addEventListener('hashchange', this.onHashChange);
},
beforeDestroy() {
this.mousetrap.unbind(keysFor(PROJECT_FILES_GO_TO_PERMALINK));
this.mousetrap.unbind(keysFor(PROJECT_FILES_COPY_FILE_PERMALINK));
window.removeEventListener('hashchange', this.onHashChange);
},
methods: {

View File

@ -252,7 +252,7 @@ export default {
};
</script>
<template>
<div>
<div data-testid="snippet-header">
<div class="gl-flex gl-flex-col gl-items-start gl-gap-3 gl-pt-3 sm:gl-flex-row">
<span
v-if="snippet.hidden"

View File

@ -596,8 +596,7 @@ export default {
@mouseout="clearPrefetching"
@removeChild="removeChild"
@error="$emit('error', $event)"
@click.stop="onClick($event, child)"
@click.native="onClick($event, child)"
@toggleDrawer="onClick($event, child)"
/>
</component>
</template>

View File

@ -292,7 +292,7 @@ export default {
:work-item-full-path="workItemFullPath"
:show-weight="shouldShowWeight"
:contextual-view-enabled="contextualViewEnabled"
@click="$emit('click', $event)"
@click="$emit('toggleDrawer', $event)"
@removeChild="$emit('removeChild', childItem)"
/>
</div>
@ -320,7 +320,7 @@ export default {
@drop="$emit('drop')"
@removeChild="$emit('removeChild', childItem)"
@error="$emit('error', $event)"
@click="$emit('click', $event)"
@click="$emit('toggleDrawer', $event)"
/>
<work-item-children-load-more
v-if="hasNextPage && isExpanded"

View File

@ -47,7 +47,6 @@ class Projects::BlobController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:inline_blame, @project)
push_frontend_feature_flag(:blob_overflow_menu, current_user)
push_frontend_feature_flag(:filter_blob_path, current_user)
push_frontend_feature_flag(:blob_repository_vue_header_app, @project)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)

View File

@ -20,7 +20,6 @@ class Projects::TreeController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:inline_blame, @project)
push_frontend_feature_flag(:blob_repository_vue_header_app, @project)
push_frontend_feature_flag(:blob_overflow_menu, current_user)
push_frontend_feature_flag(:filter_blob_path, current_user)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
push_frontend_feature_flag(:directory_code_dropdown_updates, current_user)

View File

@ -43,7 +43,6 @@ class ProjectsController < Projects::ApplicationController
# TODO: We need to remove the FF eventually when we rollout page_specific_styles
push_frontend_feature_flag(:page_specific_styles, current_user)
push_frontend_feature_flag(:blob_repository_vue_header_app, @project)
push_frontend_feature_flag(:blob_overflow_menu, current_user)
push_frontend_feature_flag(:filter_blob_path, current_user)
push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks)
push_frontend_feature_flag(:directory_code_dropdown_updates, current_user)

View File

@ -35,8 +35,8 @@ module WorkItems
context = { original: original_work_item, user: current_user }
# We need this because move work item is supposed to work with epics and for EpicWorkItem
# Issues::CloseService also enqueues a job for ::WorkItems::ValidateEpicWorkItemSyncWorker and because
# this is being run within a transaction, we are delaying the close operation until after commit.
# Issues::CloseService. Because this is being run within a transaction,
# we are delaying the close operation until after commit.
new_work_item.run_after_commit_or_now do
close_service = ::Issues::CloseService.new(
container: context[:original].namespace, current_user: context[:user]

View File

@ -3,6 +3,7 @@
page_title title
@breadcrumb_link = merge_requests_dashboard_path(assignee_username: current_user.username)
add_page_specific_style 'page_bundles/issuable_list'
@js_action_name = 'merge_requests_v2' if current_user.merge_request_dashboard_enabled? && !current_page?(merge_requests_search_dashboard_path)
= render_dashboard_ultimate_trial(current_user)
= render_product_usage_data_collection_changes(current_user)

View File

@ -1,9 +0,0 @@
---
name: blob_overflow_menu
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/450774
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/174992
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/522993
milestone: '17.10'
group: group::source code
type: beta
default_enabled: true

View File

@ -0,0 +1,8 @@
---
migration_job_name: BackfillPlaceholderUsersDetailsFromSourceUsers
description: Backfill placeholder user details for all existing placeholder users into the Import::PlaceholderUserDetail table.
feature_category: importers
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/192497
milestone: '18.1'
queued_migration_version: 20250526065102
finalized_by: # version of the migration that finalized this BBM

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
class QueueBackfillPlaceholderUsersDetailsFromSourceUsers < Gitlab::Database::Migration[2.3]
milestone '18.1'
restrict_gitlab_migration gitlab_schema: :gitlab_main
MIGRATION = "BackfillPlaceholderUsersDetailsFromSourceUsers"
BATCH_SIZE = 1000
SUB_BATCH_SIZE = 100
DELAY_INTERVAL = 2.minutes
def up
queue_batched_background_migration(
MIGRATION,
:import_source_users,
:id,
job_interval: DELAY_INTERVAL,
batch_size: BATCH_SIZE,
sub_batch_size: SUB_BATCH_SIZE
)
end
def down
delete_batched_background_migration(MIGRATION, :import_source_users, :id, [])
end
end

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
class RemoveCustomerRelationContactsForEpicType < Gitlab::Database::Migration[2.3]
milestone '18.1'
restrict_gitlab_migration gitlab_schema: :gitlab_main
disable_ddl_transaction!
BATCH_SIZE = 1000
EPIC_WORK_ITEM_TYPE_ID = 8
def up
each_batch(:issue_customer_relations_contacts, of: BATCH_SIZE) do |batch|
connection.execute(
<<~SQL
DELETE FROM issue_customer_relations_contacts
WHERE id IN (
SELECT icrc.id
FROM issue_customer_relations_contacts icrc
JOIN issues i ON icrc.issue_id = i.id
WHERE i.work_item_type_id = #{EPIC_WORK_ITEM_TYPE_ID}
AND icrc.id IN (#{batch.select(:id).to_sql})
)
SQL
)
end
end
def down
# no-op
end
end

View File

@ -0,0 +1 @@
467b28e2bf4b3952f6cb8a4f206bf6d7294f9c8fa42348e30d7a2210f55f92a0

View File

@ -0,0 +1 @@
8e0241d5b539046c0754bdde1f5821cb5f4cc1e1c3ad2d50a7f579b022af9418

View File

@ -347,6 +347,11 @@ If you frequently experience connection issues between your LDAP server and GitL
For information on adding group links by using CNs and filters, refer to the
[GitLab groups documentation](../../../user/group/access_and_permissions.md#manage-group-memberships-with-ldap).
### Link a custom admin role with an LDAP group
For information on adding custom admin role links by using CNs and filters, refer to the
[manage users using LDAP documentation](../../../user/custom_roles/_index.md#sync-ldap-groups-to-admin-roles).
### Administrator sync
As an extension of group sync, you can automatically manage your global GitLab

View File

@ -752,17 +752,6 @@ Prerequisites:
- You must be an administrator.
When you create a user, you must specify at least one of the following:
- `password`
- `reset_password`
- `force_random_password`
If `reset_password` and `force_random_password` are both `false`, then `password` is required.
`force_random_password` and `reset_password` take priority over `password`. Also, `reset_password` and
`force_random_password` can be used together.
{{< alert type="note" >}}
`private_profile` defaults to the value of the
@ -779,6 +768,10 @@ Supported attributes:
| Attribute | Required | Description |
|:-------------------------------------|:---------|:------------|
| `username` | Yes | The username of the user |
| `name` | Yes | The name of the user |
| `email` | Yes | The email of the user |
| `password` | Conditionally | The password for the user. Required if `force_random_password` or `reset_password` are not defined. If either force_random_password` or `reset_password` are defined, those settings take priority. |
| `admin` | No | User is an administrator. Valid values are `true` or `false`. Defaults to false. |
| `auditor` | No | User is an auditor. Valid values are `true` or `false`. Defaults to false. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/366404) in GitLab 15.3. Premium and Ultimate only. |
| `avatar` | No | Image file for user's avatar |
@ -786,24 +779,21 @@ Supported attributes:
| `can_create_group` | No | User can create top-level groups - true or false |
| `color_scheme_id` | No | User's color scheme for the file viewer (for more information, see the [user preference documentation](../user/profile/preferences.md#change-the-syntax-highlighting-theme)) |
| `commit_email` | No | User's commit email address |
| `email` | Yes | Email |
| `extern_uid` | No | External UID |
| `external` | No | Flags the user as external - true or false (default) |
| `extra_shared_runners_minutes_limit` | No | Can be set by administrators only. Additional compute minutes for this user. Premium and Ultimate only. |
| `force_random_password` | No | Set user password to a random value - true or false (default) |
| `force_random_password` | No | If `true`, set the user password to a random value. Can be used with `reset_password`. Takes priority over `password`. |
| `group_id_for_saml` | No | ID of group where SAML has been configured |
| `linkedin` | No | LinkedIn |
| `location` | No | User's location |
| `name` | Yes | Name |
| `note` | No | Administrator notes for this user |
| `organization` | No | Organization name |
| `password` | No | Password |
| `private_profile` | No | User's profile is private - true or false. The default value is determined by [a setting](../administration/settings/account_and_limit_settings.md#set-profiles-of-new-users-to-private-by-default). |
| `projects_limit` | No | Number of projects user can create |
| `pronouns` | No | User's pronouns |
| `provider` | No | External provider name |
| `public_email` | No | User's public email address |
| `reset_password` | No | Send user password reset link - true or false(default) |
| `reset_password` | No | If `true`, sends the user a link to reset their password. Can be used with `force_random_password`. Takes priority over `password`. |
| `shared_runners_minutes_limit` | No | Can be set by administrators only. Maximum number of monthly compute minutes for this user. Can be `nil` (default; inherit system default), `0` (unlimited), or `> 0`. Premium and Ultimate only. |
| `skip_confirmation` | No | Skip confirmation - true or false (default) |
| `skype` | No | Skype ID |
@ -811,7 +801,6 @@ Supported attributes:
| `twitter` | No | X (formerly Twitter) account |
| `discord` | No | Discord account |
| `github` | No | GitHub username |
| `username` | Yes | Username |
| `view_diffs_file_by_file` | No | Flag indicating the user sees only one file diff per page |
| `website_url` | No | Website URL |
@ -854,7 +843,7 @@ Supported attributes:
| `can_create_group` | No | User can create groups - true or false |
| `color_scheme_id` | No | User's color scheme for the file viewer (for more information, see the [user preference documentation](../user/profile/preferences.md#change-the-syntax-highlighting-theme) for more information) |
| `commit_email` | No | User's commit email. Set to `_private` to use the private commit email. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/375148) in GitLab 15.5. |
| `email` | No | Email |
| `email` | No | The email of the user |
| `extern_uid` | No | External UID |
| `external` | No | Flags the user as external - true or false (default) |
| `extra_shared_runners_minutes_limit` | No | Can be set by administrators only. Additional compute minutes for this user. Premium and Ultimate only. |
@ -862,10 +851,10 @@ Supported attributes:
| `id` | Yes | ID of the user |
| `linkedin` | No | LinkedIn |
| `location` | No | User's location |
| `name` | No | Name |
| `name` | No | The name of the user |
| `note` | No | Administration notes for this user |
| `organization` | No | Organization name |
| `password` | No | Password |
| `password` | No | The password for the user |
| `private_profile` | No | User's profile is private - true or false. |
| `projects_limit` | No | Limit projects each user can create |
| `pronouns` | No | Pronouns |
@ -878,7 +867,7 @@ Supported attributes:
| `twitter` | No | X (formerly Twitter) account |
| `discord` | No | Discord account |
| `github` | No | GitHub username |
| `username` | No | Username |
| `username` | No | The username of the user |
| `view_diffs_file_by_file` | No | Flag indicating the user sees only one file diff per page |
| `website_url` | No | Website URL |

View File

@ -1084,12 +1084,10 @@ Examples:
### Link to specific lines of code
<!-- when blob_overflow_menu is removed, change 'button' to 'dropdown item' -->
When linking to specific lines in a file, link to a commit instead of to the
branch. Lines of code change over time. Linking to a line by using
the commit link ensures the user lands on the line you're referring to. The
**Permalink** button, displayed when viewing a file in a project,
**Permalink** dropdown item in the ellipsis menu, displayed when viewing a file in a project,
provides a link to the most recent commit of that file.
- Do: `[link to line 3](https://gitlab.com/gitlab-org/gitlab/-/blob/11f17c56d8b7f0b752562d78a4298a3a95b5ce66/.gitlab/issue_templates/Feature%20proposal.md#L3)`
@ -1696,7 +1694,7 @@ covered in the documentation, you should:
- Add this detail to the documentation text.
- Create an issue to review the video and update the page.
Do not upload videos to the product repositories. [Link](#link-to-video) or
Do not upload videos to the product repositories. [Add a link](#link-to-video) or
[embed](#embed-videos) them instead.
### Link to video
@ -1719,7 +1717,7 @@ The [GitLab documentation site](https://docs.gitlab.com) supports embedded
videos.
You can embed videos from [the official YouTube account for GitLab](https://www.youtube.com/channel/UCnMGQ8QHMAnVIsI3xJrihhg) only.
For videos from other sources, [link](#link-to-video) them instead.
For videos from other sources, [link them](#link-to-video) instead.
In most cases, [link to a video](#link-to-video), because
embedded videos take up a lot of space on the page and can be distracting to readers.

View File

@ -268,6 +268,57 @@ users to custom roles. For more information, see:
- [Configure SAML Group Links](../group/saml_sso/group_sync.md#configure-saml-group-links).
- [Manage group memberships via LDAP](../group/access_and_permissions.md#manage-group-memberships-with-ldap).
## Sync LDAP groups to admin roles
You can link a custom admin role to an LDAP group. This link assigns the custom admin role to all users in the group.
If a user belongs to multiple LDAP groups with different assigned custom admin roles, GitLab assigns the role associated with whichever LDAP link was created earlier. For example, if a user is a member of the LDAP groups `owner` and `dev`. If the `owner` group was linked to a custom admin role before the `dev` group, the user would be assigned the role associated with the `owner` group.
For more information on the administration of LDAP and group sync, see [LDAP synchronization](../../administration/auth/ldap/ldap_synchronization.md#group-sync).
{{< alert type="note" >}}
If an LDAP user with a custom admin role is removed from the LDAP group after configuring a sync, the custom role is not removed until the next sync.
{{< /alert >}}
### Link a custom admin role with an LDAP CN
Prerequisites:
- You must have integrated an LDAP server with your instance.
To link a custom admin role with an LDAP CN:
1. On the left sidebar, at the bottom, select **Admin**.
1. Select **Settings > Roles and permissions**.
1. On the **LDAP Synchronization** tab, select an **LDAP Server**.
1. In the **Sync method** field, select `Group cn`.
1. In the **Group cn** field, begin typing the CN of the group. A dropdown list appears with matching CNs in the configured `group_base`.
1. From the dropdown list, select your CN.
1. In the **Custom admin role** field, select a custom admin role.
1. Select **Add**.
GitLab begins linking the role to any matching LDAP users. This process may take over an hour to complete.
### Link a custom admin role with an LDAP filter
Prerequisites:
- You must have integrated an LDAP server with your instance.
To link a custom admin role with an LDAP filter:
1. On the left sidebar, at the bottom, select **Admin**.
1. Select **Settings > Roles and permissions**.
1. On the **LDAP Synchronization** tab, select an **LDAP Server**.
1. In the **Sync method** field, select `User filter`.
1. In **User filter** box, enter a filter. For details, see [Set up LDAP user filter](../../administration/auth/ldap/_index.md#set-up-ldap-user-filter).
1. In the **Custom admin role** field, select a custom admin role.
1. Select **Add**.
GitLab begins linking the role to any matching LDAP users. This process may take over an hour to complete.
## Contribute new permissions
If a permission does not exist, you can:

View File

@ -157,3 +157,7 @@ used in the search.
Because this is an experimental feature, your feedback is valuable in helping us improve it.
Share your experiences, suggestions, or issues in [issue 542198](https://gitlab.com/gitlab-org/gitlab/-/issues/542198).
## Related topics
- [Blog: GitLab Duo Chat gets agentic AI makeover](https://about.gitlab.com/blog/2025/05/29/gitlab-duo-chat-gets-agentic-ai-makeover/)

View File

@ -82,15 +82,10 @@ similar functionality for locked files is discussed in
{{< history >}}
- [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/519325) in GitLab 17.10 [with a flag](../../administration/feature_flags.md) named `blob_overflow_menu`. Disabled by default.
- [Generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/522993) in GitLab 18.1. Feature flag `blob_overflow_menu` removed.
{{< /history >}}
{{< alert type="flag" >}}
The availability of this feature is controlled by a feature flag. For more information, see the history.
{{< /alert >}}
To lock a file:
1. On the left sidebar, select **Search or go to** and find your project.

View File

@ -114,6 +114,13 @@ use it for the project's group.
## Configure group insights
{{< alert type="note" >}}
Creating a custom Insights configuration file will override the default configuration.
If you want to retain the original configuration, you should copy the content of the [default configuration file](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/fixtures/insights/default.yml) as a base.
{{< /alert >}}
GitLab reads insights from the
[default configuration file](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/fixtures/insights/default.yml).

View File

@ -263,15 +263,10 @@ To cancel changes, edit, upload, or delete a file, from the Web Editor:
{{< history >}}
- [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/519325) in GitLab 17.10 [with a flag](../../../administration/feature_flags.md) named `blob_overflow_menu`. Disabled by default.
- [Generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/522993) in GitLab 18.1. Feature flag `blob_overflow_menu` removed.
{{< /history >}}
{{< alert type="flag" >}}
The availability of this feature is controlled by a feature flag. For more information, see the history.
{{< /alert >}}
The **Actions** ({{< icon name="ellipsis_v" >}}) menu consolidates file operations into the
dropdown list. From this menu, you can:

View File

@ -0,0 +1,104 @@
# frozen_string_literal: true
module Gitlab
module BackgroundMigration
class BackfillPlaceholderUsersDetailsFromSourceUsers < BatchedMigrationJob
operation_name :backfill_placeholder_users_details_from_source_users
feature_category :importers
class ImportSourceUser < ApplicationRecord
self.table_name = 'import_source_users'
belongs_to :namespace
end
class Namespace < ApplicationRecord
self.table_name = 'namespaces'
end
class ImportPlaceholderUserDetail < ApplicationRecord
self.table_name = 'import_placeholder_user_details'
end
def perform
each_sub_batch do |sub_batch|
backfill_placeholder_user_details(sub_batch)
end
end
private
def backfill_placeholder_user_details(sub_batch)
source_users_with_data = prepare_source_users_data(sub_batch)
return if source_users_with_data.empty?
placeholder_user_ids = source_users_with_data.keys
existing_placeholder_ids = get_existing_placeholder_ids(placeholder_user_ids)
users_to_process = source_users_with_data.reject do |placeholder_id, _|
existing_placeholder_ids.include?(placeholder_id)
end
return if users_to_process.empty?
bulk_insert_placeholder_details(users_to_process)
end
def prepare_source_users_data(sub_batch)
source_users = ImportSourceUser
.where(id: sub_batch)
.joins(:namespace)
.where.not(placeholder_user_id: nil)
.where.not(namespace_id: nil)
.select(:placeholder_user_id, :namespace_id, 'namespaces.organization_id as organization_id')
source_users.each_with_object({}) do |user, hash|
hash[user.placeholder_user_id] = {
namespace_id: user.namespace_id,
organization_id: user.organization_id
}
end
end
def get_existing_placeholder_ids(placeholder_user_ids)
return [] if placeholder_user_ids.empty?
ImportPlaceholderUserDetail
.where(placeholder_user_id: placeholder_user_ids)
.pluck(:placeholder_user_id)
end
def bulk_insert_placeholder_details(users_data)
return if users_data.empty?
timestamp = Time.current
records_to_insert = users_data.map do |placeholder_user_id, data|
{
placeholder_user_id: placeholder_user_id,
namespace_id: data[:namespace_id],
organization_id: data[:organization_id],
created_at: timestamp,
updated_at: timestamp
}
end
begin
ImportPlaceholderUserDetail.upsert_all(records_to_insert)
rescue StandardError => e
logger.error(
message: "Error bulk creating placeholder user details: #{e.message}",
count: users_data.size,
placeholder_user_ids: users_data.keys
)
raise e
end
end
def logger
@logger ||= Gitlab::BackgroundMigration::Logger.build
end
end
end
end

View File

@ -7,7 +7,7 @@ module Gitlab
scope_to ->(relation) { relation.where.not(pipeline_id: nil) }
feature_category :dependency_management
class DependencyListExport < ::ApplicationRecord
class DependencyListExport < SecApplicationRecord
FINISHED = 2
FAILED = -1

View File

@ -29257,9 +29257,6 @@ msgstr ""
msgid "Go to file"
msgstr ""
msgid "Go to file permalink (while viewing a file)"
msgstr ""
msgid "Go to files"
msgstr ""
@ -71206,9 +71203,6 @@ msgstr ""
msgid "You cant edit files directly in this project."
msgstr ""
msgid "You cant edit files directly in this project. Fork this project and submit a merge request with your changes."
msgstr ""
msgid "You commented"
msgstr ""

View File

@ -61,7 +61,7 @@ module Gitlab
resources: resources("300m", "300Mi")
},
toolbox: {
resources: resources("50m", "128Mi", "1100m", "1Gi")
resources: resources("50m", "128Mi", no_limits: true)
}
},
registry: {
@ -124,7 +124,7 @@ module Gitlab
# Toolbox create peak load during startup but then consumes very little
# Set high limit value but don't request full amount to avoid unnecessary lock
toolbox: {
resources: resources("50m", "128Mi", "1100m", "1Gi")
resources: resources("50m", "128Mi", no_limits: true)
}
},
registry: {

View File

@ -44,8 +44,7 @@ RSpec.describe Gitlab::Orchestrator::Deployment::ResourcePresets do
},
toolbox: {
resources: {
requests: { cpu: "50m", memory: "128Mi" },
limits: { cpu: "1100m", memory: "1Gi" }
requests: { cpu: "50m", memory: "128Mi" }
}
}
},
@ -159,8 +158,7 @@ RSpec.describe Gitlab::Orchestrator::Deployment::ResourcePresets do
},
toolbox: {
resources: {
requests: { cpu: "50m", memory: "128Mi" },
limits: { cpu: "1100m", memory: "1Gi" }
requests: { cpu: "50m", memory: "128Mi" }
}
}
},

View File

@ -9,10 +9,6 @@ module QA
include Page::Component::BlobContent
include Shared::Editor
view 'app/assets/javascripts/repository/components/blob_button_group.vue' do
element 'lock-button'
end
view 'app/assets/javascripts/vue_shared/components/web_ide_link.vue' do
element 'action-dropdown'
element 'edit-menu-item', ':data-testid="`${action.key}-menu-item`"' # rubocop:disable QA/ElementWithPattern

View File

@ -4,8 +4,7 @@ module QA
RSpec.describe 'Tenant Scale' do
describe(
'User', :requires_admin,
product_group: :organizations,
feature_flag: { name: :blob_overflow_menu }
product_group: :organizations
) do
let(:admin_api_client) { Runtime::API::Client.as_admin }
@ -18,7 +17,6 @@ module QA
context 'with terminated parent group membership' do
before do
group.add_member(user)
Runtime::Feature.enable(:blob_overflow_menu)
Flow::Login.while_signed_in_as_admin do
group.visit!

View File

@ -4,8 +4,7 @@ module QA
RSpec.describe 'Tenant Scale' do
describe(
'User', :requires_admin,
product_group: :organizations,
feature_flag: { name: :blob_overflow_menu }
product_group: :organizations
) do
let!(:parent_group) do
create(:group, path: "parent-group-to-test-user-access-#{SecureRandom.hex(8)}")
@ -25,7 +24,6 @@ module QA
before do
parent_group.add_member(parent_group_user)
Runtime::Feature.enable(:blob_overflow_menu)
end
it(

View File

@ -1,97 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js, feature_category: :source_code_management do
include TreeHelper
let(:project) { create(:project, :public, :repository) }
let(:path) { 'CHANGELOG' }
let(:sha) { project.repository.commit.sha }
describe 'On a file(blob)' do
def get_absolute_url(path = "")
"http://#{page.server.host}:#{page.server.port}#{path}"
end
def visit_blob(fragment = nil)
visit project_blob_path(project, tree_join('master', path), anchor: fragment)
end
describe 'Click "Permalink" button' do
before do
stub_feature_flags(blob_overflow_menu: false)
end
it 'works with no initial line number fragment hash' do
visit_blob
expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path))))
end
it 'maintains initial fragment hash' do
fragment = "L3"
visit_blob(fragment)
expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: fragment)))
end
it 'changes fragment hash if line number clicked' do
visit_blob
find('#L3').click
find("#L5").click
expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "L5")))
end
it 'with initial fragment hash, changes fragment hash if line number clicked' do
fragment = "L1"
visit_blob(fragment)
find('#L3').click
find("#L5").click
expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "L5")))
end
end
describe 'Click "Blame" button' do
it 'works with no initial line number fragment hash' do
visit_blob
expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path))))
end
it 'maintains initial fragment hash' do
fragment = "L3"
visit_blob(fragment)
expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: fragment)))
end
it 'changes fragment hash if line number clicked' do
visit_blob
find('#L3').click
find("#L5").click
expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "L5")))
end
it 'with initial fragment hash, changes fragment hash if line number clicked' do
fragment = "L1"
visit_blob(fragment)
find('#L3').click
find("#L5").click
expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "L5")))
end
end
end
end

View File

@ -1,59 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Blob shortcuts', :js, feature_category: :groups_and_projects do
include TreeHelper
let(:project) { create(:project, :public, :repository) }
let(:path) { project.repository.ls_files(project.repository.root_ref)[0] }
let(:sha) { project.repository.commit.sha }
describe 'On a file(blob)', :js do
def get_absolute_url(path = "")
"http://#{page.server.host}:#{page.server.port}#{path}"
end
def visit_blob(fragment = nil)
visit project_blob_path(project, tree_join('master', path), anchor: fragment)
end
describe 'pressing "y"' do
before do
stub_feature_flags(blob_overflow_menu: false)
end
it 'redirects to permalink with commit sha' do
visit_blob
wait_for_requests
find('body').native.send_key('y')
expect(page).to have_current_path(get_absolute_url(project_blob_path(project, tree_join(sha, path))), url: true)
end
it 'redirects to permalink of a currently viewed file' do
visit project_path(project)
wait_for_requests
click_link 'VERSION'
wait_for_requests
page.driver.go_back
click_link path
wait_for_requests
find('body').native.send_key('y')
expect(page).to have_current_path(get_absolute_url(project_blob_path(project, tree_join(sha, path))), url: true)
end
it 'maintains fragment hash when redirecting' do
fragment = "L1"
visit_blob(fragment)
wait_for_requests
find('body').native.send_key('y')
expect(page).to have_current_path(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: fragment)), url: true)
end
end
end
end

View File

@ -24,8 +24,6 @@ RSpec.describe 'Projects > Files > User edits files', :js, feature_category: :so
let_it_be(:project_with_crlf) { create(:project, :custom_repo, name: 'Project with crlf', files: { 'crlf_file.txt' => crlf_text }) }
before do
stub_feature_flags(blob_overflow_menu: false)
sign_in(user)
end
@ -40,8 +38,11 @@ RSpec.describe 'Projects > Files > User edits files', :js, feature_category: :so
expect(page).not_to have_text('Edit')
expect(page).not_to have_text('Web IDE')
expect(page).not_to have_text('Replace')
expect(page).not_to have_text('Delete')
within_testid('blob-controls') do
click_button 'File actions'
expect(page).not_to have_text('Replace')
expect(page).not_to have_text('Delete')
end
end
end
end
@ -172,8 +173,8 @@ RSpec.describe 'Projects > Files > User edits files', :js, feature_category: :so
expect(page).to have_selector(:link_or_button, 'Fork')
expect(page).to have_selector(:link_or_button, 'Cancel')
expect(page).to have_content(
"You cant edit files directly in this project. "\
"Fork this project and submit a merge request with your changes."
"You're not allowed to make changes to this project directly. "\
"Create a fork to make changes and submit a merge request."
)
end
@ -194,6 +195,8 @@ RSpec.describe 'Projects > Files > User edits files', :js, feature_category: :so
expect_fork_status
edit_in_single_file_editor
find('.file-editor', match: :first)
editor_set_value('*.rbca')
@ -210,9 +213,21 @@ RSpec.describe 'Projects > Files > User edits files', :js, feature_category: :so
click_link_or_button('Fork')
within_web_ide do
expect(page).to have_text(project2.path.upcase)
expect(page).to have_text('.gitignore')
expect_fork_status
click_button 'Edit'
new_tab = window_opened_by { click_link_or_button 'Web IDE' }
switch_to_window new_tab
wait_for_requests
within_window new_tab do
within_web_ide do
expect(page).to have_text(project2.path.upcase)
expect(page).to have_text('.gitignore')
end
end
end
@ -223,6 +238,8 @@ RSpec.describe 'Projects > Files > User edits files', :js, feature_category: :so
expect_fork_prompt
click_link_or_button('Fork')
edit_in_single_file_editor
find('.file-editor', match: :first)
editor_set_value('*.rbca')

View File

@ -13,9 +13,12 @@ RSpec.describe 'Public Snippets', :js, feature_category: :source_code_management
wait_for_requests
expect(page).to have_content(content)
click_button('Code')
expect(page).to have_field('Embed', readonly: true, with: "<script src=\"#{url}.js\"></script>")
expect(page).to have_field('Share', readonly: true, with: url)
within_testid('snippet-header') do
click_button('Code')
expect(page).to have_field('Embed', readonly: true, with: "<script src=\"#{url}.js\"></script>")
expect(page).to have_field('Share', readonly: true, with: url)
end
end
it 'unauthenticated user should see raw public snippets' do

View File

@ -1,56 +0,0 @@
import ShortcutsBlob from '~/behaviors/shortcuts/shortcuts_blob';
import { PROJECT_FILES_GO_TO_PERMALINK } from '~/behaviors/shortcuts/keybindings';
import { moveToFilePermalink } from '~/blob/utils';
describe('ShortcutsBlob', () => {
const shortcuts = {
add: jest.fn(),
};
const init = () => {
return new ShortcutsBlob(shortcuts);
};
beforeEach(() => {
shortcuts.add.mockClear();
window.gon = {};
});
describe('constructor', () => {
describe('when shortcuts should be added', () => {
it('adds the permalink shortcut when gon.features is undefined', () => {
init();
expect(shortcuts.add).toHaveBeenCalledWith(
PROJECT_FILES_GO_TO_PERMALINK,
moveToFilePermalink,
);
});
it('adds shortcuts when blobOverflowMenu is false', () => {
window.gon.features = {
blobOverflowMenu: false,
};
init();
expect(shortcuts.add).toHaveBeenCalledWith(
PROJECT_FILES_GO_TO_PERMALINK,
moveToFilePermalink,
);
});
});
describe('when shortcuts should not be added', () => {
it('does not add shortcuts when blobOverflowMenu is true', () => {
window.gon.features = {
blobOverflowMenu: true,
};
init();
expect(shortcuts.add).not.toHaveBeenCalled();
});
});
});
});

View File

@ -19,9 +19,6 @@ describe('Blob Header Default Actions', () => {
wrapper = shallowMountExtended(BlobHeaderActions, {
provide: {
blobHash,
glFeatures: {
blobOverflowMenu: false,
},
...provided,
},
propsData: {
@ -136,9 +133,9 @@ describe('Blob Header Default Actions', () => {
});
});
describe('when blob_overflow_menu is enabled', () => {
describe('default actions layout', () => {
it('hides default actions for mobile layout', () => {
createComponent({}, { glFeatures: { blobOverflowMenu: true } });
createComponent();
expect(wrapper.findComponent(GlButtonGroup).attributes('class')).toBe(
'gl-hidden sm:gl-inline-flex',

View File

@ -1,5 +1,3 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import BlobHeader from '~/blob/components/blob_header.vue';
import DefaultActions from '~/blob/components/blob_header_default_actions.vue';
@ -7,21 +5,15 @@ import BlobFilepath from '~/blob/components/blob_header_filepath.vue';
import ViewerSwitcher from '~/blob/components/blob_header_viewer_switcher.vue';
import { SIMPLE_BLOB_VIEWER } from '~/blob/components/constants';
import TableContents from '~/blob/components/table_contents.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import WebIdeLink from 'ee_else_ce/vue_shared/components/web_ide_link.vue';
import userInfoQuery from '~/blob/queries/user_info.query.graphql';
import applicationInfoQuery from '~/blob/queries/application_info.query.graphql';
import { Blob, userInfoMock, applicationInfoMock } from './mock_data';
Vue.use(VueApollo);
import { Blob } from './mock_data';
describe('Blob Header Default Actions', () => {
let wrapper;
const defaultProvide = {
blobHash: 'foo-bar',
glFeatures: { blobOverflowMenu: true },
};
const findDefaultActions = () => wrapper.findComponent(DefaultActions);
@ -30,7 +22,6 @@ describe('Blob Header Default Actions', () => {
const findBlobFilePath = () => wrapper.findComponent(BlobFilepath);
const findRichTextEditorBtn = () => wrapper.findByTestId('rich-blob-viewer-button');
const findSimpleTextEditorBtn = () => wrapper.findByTestId('simple-blob-viewer-button');
const findWebIdeLink = () => wrapper.findComponent(WebIdeLink);
const findDuoWorkflowActionSlot = () => wrapper.findByTestId('ee-duo-workflow-action');
async function createComponent({
@ -39,21 +30,7 @@ describe('Blob Header Default Actions', () => {
propsData = {},
mountFn = shallowMountExtended,
} = {}) {
const userInfoMockResolver = jest.fn().mockResolvedValue({
data: { ...userInfoMock },
});
const applicationInfoMockResolver = jest.fn().mockResolvedValue({
data: { ...applicationInfoMock },
});
const fakeApollo = createMockApollo([
[userInfoQuery, userInfoMockResolver],
[applicationInfoQuery, applicationInfoMockResolver],
]);
wrapper = mountFn(BlobHeader, {
apolloProvider: fakeApollo,
provide: {
...defaultProvide,
},
@ -75,87 +52,6 @@ describe('Blob Header Default Actions', () => {
createComponent();
});
describe('WebIdeLink component', () => {
it('does not render WebIdeLink component', () => {
expect(findWebIdeLink().exists()).toBe(false);
});
describe('when blob_overflow_menu feature flag is false', () => {
it('renders the WebIdeLink component with the correct props', async () => {
const { ideEditPath, editBlobPath, gitpodBlobUrl, pipelineEditorPath } = Blob;
const showForkSuggestion = false;
const showWebIdeForkSuggestion = false;
await createComponent({
options: {
provide: {
glFeatures: { blobOverflowMenu: false },
},
},
propsData: { showForkSuggestion, showWebIdeForkSuggestion },
});
expect(findWebIdeLink().props()).toMatchObject({
showEditButton: true,
buttonVariant: 'confirm',
editUrl: editBlobPath,
webIdeUrl: ideEditPath,
needsToFork: showForkSuggestion,
needsToForkWithWebIde: showWebIdeForkSuggestion,
showPipelineEditorButton: Boolean(pipelineEditorPath),
pipelineEditorUrl: pipelineEditorPath,
gitpodUrl: gitpodBlobUrl,
isGitpodEnabledForInstance: applicationInfoMock.gitpodEnabled,
isGitpodEnabledForUser: userInfoMock.currentUser.gitpodEnabled,
disabled: false,
});
});
it('disables the WebIdeLink component when file is LFS', async () => {
await createComponent({
options: {
provide: {
glFeatures: { blobOverflowMenu: false },
},
},
propsData: { isUsingLfs: true },
});
expect(findWebIdeLink().props('disabled')).toBe(true);
});
it('passes the edit button variant down to the WebIdeLink', () => {
const editButtonVariant = 'danger';
createComponent({
options: {
provide: {
glFeatures: { blobOverflowMenu: false },
},
},
propsData: { editButtonVariant },
});
expect(findWebIdeLink().props('buttonVariant')).toBe(editButtonVariant);
});
it.each([[{ archived: true }], [{ editBlobPath: null }]])(
'does not render the WebIdeLink component when blob is archived or does not have an edit path',
(blobProps) => {
createComponent({
blobProps,
options: {
provide: {
glFeatures: { blobOverflowMenu: false },
},
},
});
expect(findWebIdeLink().exists()).toBe(false);
},
);
});
});
describe('default render', () => {
it.each`
findComponent | componentName
@ -275,23 +171,5 @@ describe('Blob Header Default Actions', () => {
expect(wrapper.emitted('viewer-changed')).toBeDefined();
});
it('sets different icons depending on the blob file type', async () => {
factory();
expect(findViewSwitcher().props('docIcon')).toBe('document');
await wrapper.setProps({
blob: {
...Blob,
richViewer: {
...Blob.richViewer,
fileType: 'csv',
},
},
});
expect(findViewSwitcher().props('docIcon')).toBe('table');
});
});
});

View File

@ -14,12 +14,9 @@ describe('Blob Header Viewer Switcher', () => {
let wrapper;
let trackingSpy;
function createComponent(propsData = { showViewerToggles: true }, featureFlag = false) {
function createComponent(propsData = { showViewerToggles: true }) {
wrapper = mountExtended(BlobHeaderViewerSwitcher, {
propsData,
provide: {
glFeatures: { blobOverflowMenu: featureFlag },
},
});
}
@ -126,22 +123,10 @@ describe('Blob Header Viewer Switcher', () => {
);
});
describe('with blobOverflowMenu feature flag', () => {
it('renders icon toggles, when flag is disabled', () => {
createComponent();
expect(findSimpleViewerButton().props('icon')).toBe('code');
expect(findSimpleViewerButton().text()).toBe('');
expect(findRichViewerButton().props('icon')).toBe('document');
expect(findRichViewerButton().text()).toBe('');
});
it('renders text toggles, when flag is enabled', () => {
createComponent({ showViewerToggles: true }, true);
expect(findSimpleViewerButton().props('icon')).toBe('');
describe('viewer toggles', () => {
it('renders text toggles', () => {
createComponent({ showViewerToggles: true });
expect(findSimpleViewerButton().text()).toBe('Code');
expect(findRichViewerButton().props('icon')).toBe('');
expect(findRichViewerButton().text()).toBe('Preview');
});
});

View File

@ -83,5 +83,3 @@ export const userInfoMock = {
profileEnableGitpodPath: '/-/profile?user%5Bgitpod_enabled%5D=true',
},
};
export const applicationInfoMock = { gitpodEnabled: true };

View File

@ -1,160 +0,0 @@
import { GlButton } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { stubComponent } from 'helpers/stub_component';
import BlobButtonGroup from '~/repository/components/blob_button_group.vue';
import CommitChangesModal from '~/repository/components/commit_changes_modal.vue';
import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
const DEFAULT_PROPS = {
name: 'some name',
path: 'some/path',
canPushCode: true,
canPushToBranch: true,
replacePath: 'some/replace/path',
deletePath: 'some/delete/path',
emptyRepo: false,
projectPath: 'some/project/path',
isLocked: false,
canLock: true,
showForkSuggestion: false,
isUsingLfs: true,
};
const DEFAULT_INJECT = {
targetBranch: 'master',
originalBranch: 'master',
};
describe('BlobButtonGroup component', () => {
let wrapper;
let showUploadBlobModalMock;
let showDeleteBlobModalMock;
const createComponent = (props = {}) => {
showUploadBlobModalMock = jest.fn();
showDeleteBlobModalMock = jest.fn();
const UploadBlobModalStub = stubComponent(UploadBlobModal, {
methods: {
show: showUploadBlobModalMock,
},
});
const DeleteBlobModalStub = stubComponent(CommitChangesModal, {
methods: {
show: showDeleteBlobModalMock,
},
});
wrapper = mountExtended(BlobButtonGroup, {
propsData: {
...DEFAULT_PROPS,
...props,
},
provide: {
...DEFAULT_INJECT,
},
stubs: {
UploadBlobModal: UploadBlobModalStub,
CommitChangesModal: DeleteBlobModalStub,
},
});
};
const findDeleteBlobModal = () => wrapper.findComponent(CommitChangesModal);
const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal);
const findDeleteButton = () => wrapper.findByTestId('delete');
const findReplaceButton = () => wrapper.findByTestId('replace');
it('renders component', () => {
createComponent();
const { name, path } = DEFAULT_PROPS;
expect(wrapper.props()).toMatchObject({
name,
path,
});
});
describe('buttons', () => {
beforeEach(() => {
createComponent();
});
it('renders both the replace and delete button', () => {
expect(wrapper.findAllComponents(GlButton)).toHaveLength(2);
});
it('renders the buttons in the correct order', () => {
expect(wrapper.findAllComponents(GlButton).at(0).text()).toBe('Replace');
expect(wrapper.findAllComponents(GlButton).at(1).text()).toBe('Delete');
});
it('triggers the UploadBlobModal from the replace button', () => {
findReplaceButton().vm.$emit('click');
expect(showUploadBlobModalMock).toHaveBeenCalled();
});
it('triggers the CommitChangesModal from the delete button', () => {
findDeleteButton().vm.$emit('click');
expect(showDeleteBlobModalMock).toHaveBeenCalled();
});
describe('showForkSuggestion set to true', () => {
beforeEach(() => {
createComponent({ showForkSuggestion: true });
});
it('does not trigger the UploadBlobModal from the replace button', () => {
findReplaceButton().vm.$emit('click');
expect(showUploadBlobModalMock).not.toHaveBeenCalled();
expect(wrapper.emitted().fork).toHaveLength(1);
});
it('does not trigger the DeleteBlobModal from the delete button', () => {
findDeleteButton().vm.$emit('click');
expect(showDeleteBlobModalMock).not.toHaveBeenCalled();
expect(wrapper.emitted().fork).toHaveLength(1);
});
});
});
it('renders UploadBlobModal', () => {
createComponent();
const { targetBranch, originalBranch } = DEFAULT_INJECT;
const { name, path, canPushCode, replacePath } = DEFAULT_PROPS;
const title = `Replace ${name}`;
expect(findUploadBlobModal().props()).toMatchObject({
commitMessage: title,
targetBranch,
originalBranch,
canPushCode,
path,
replacePath,
});
});
it('renders CommitChangesModal for delete', () => {
createComponent();
const { targetBranch, originalBranch } = DEFAULT_INJECT;
const { name, canPushCode, emptyRepo, isUsingLfs } = DEFAULT_PROPS;
const title = `Delete ${name}`;
expect(findDeleteBlobModal().props()).toMatchObject({
commitMessage: title,
targetBranch,
originalBranch,
canPushCode,
emptyRepo,
isUsingLfs,
});
});
});

View File

@ -11,9 +11,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/alert';
import BlobContent from '~/blob/components/blob_content.vue';
import BlobHeader from 'ee_else_ce/blob/components/blob_header.vue';
import BlobButtonGroup from '~/repository/components/blob_button_group.vue';
import BlobContentViewer from '~/repository/components/blob_content_viewer.vue';
import ForkSuggestion from '~/repository/components/fork_suggestion.vue';
import { loadViewer } from '~/repository/components/blob_viewers';
import DownloadViewer from '~/repository/components/blob_viewers/download_viewer.vue';
import EmptyViewer from '~/repository/components/blob_viewers/empty_viewer.vue';
@ -150,8 +148,6 @@ describe('Blob content viewer component', () => {
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findBlobHeader = () => wrapper.findComponent(BlobHeader);
const findBlobContent = () => wrapper.findComponent(BlobContent);
const findBlobButtonGroup = () => wrapper.findComponent(BlobButtonGroup);
const findForkSuggestion = () => wrapper.findComponent(ForkSuggestion);
const findCodeIntelligence = () => wrapper.findComponent(CodeIntelligence);
const findSourceViewer = () => wrapper.findComponent(SourceViewer);
@ -178,9 +174,7 @@ describe('Blob content viewer component', () => {
expect(findBlobHeader().props('hasRenderError')).toEqual(false);
expect(findBlobHeader().props('hideViewerSwitcher')).toEqual(false);
expect(findBlobHeader().props('blob')).toEqual(simpleViewerMock);
expect(findBlobHeader().props('showForkSuggestion')).toEqual(false);
expect(findBlobHeader().props('showBlameToggle')).toEqual(true);
expect(findBlobHeader().props('projectPath')).toEqual(propsMock.projectPath);
expect(findBlobHeader().props('projectId')).toEqual(projectMock.id);
expect(mockRouterPush).not.toHaveBeenCalled();
});
@ -503,40 +497,6 @@ describe('Blob content viewer component', () => {
expect(findBlobHeader().props('isBinary')).toBe(true);
});
});
describe('BlobButtonGroup', () => {
const { name, path, replacePath, webPath } = simpleViewerMock;
const {
userPermissions: { pushCode, downloadCode },
repository: { empty },
} = projectMock;
it('renders component', async () => {
window.gon.current_user_id = 'gid://gitlab/User/1';
window.gon.current_username = 'root';
await createComponent({ pushCode, downloadCode, empty }, mount);
expect(findBlobButtonGroup().props()).toMatchObject({
name,
path,
replacePath,
deletePath: webPath,
canPushCode: pushCode,
canLock: true,
isLocked: false,
emptyRepo: empty,
});
});
it('does not render if not logged in', async () => {
isLoggedIn.mockReturnValueOnce(false);
await createComponent();
expect(findBlobButtonGroup().exists()).toBe(false);
});
});
});
describe('blob info query', () => {
@ -581,76 +541,6 @@ describe('Blob content viewer component', () => {
findBlobHeader().vm.$emit('edit', 'ide');
expect(urlUtility.visitUrl).toHaveBeenCalledWith(simpleViewerMock.ideEditPath);
});
it.each`
loggedIn | canModifyBlob | isUsingLfs | createMergeRequestIn | forkProject | showSingleFileEditorForkSuggestion
${true} | ${true} | ${false} | ${true} | ${true} | ${false}
${true} | ${false} | ${false} | ${true} | ${true} | ${true}
${false} | ${false} | ${false} | ${true} | ${true} | ${false}
${true} | ${false} | ${false} | ${false} | ${true} | ${false}
${true} | ${false} | ${false} | ${true} | ${false} | ${false}
${true} | ${false} | ${true} | ${true} | ${true} | ${false}
`(
'shows/hides a fork suggestion according to a set of conditions',
async ({
loggedIn,
canModifyBlob,
isUsingLfs,
createMergeRequestIn,
forkProject,
showSingleFileEditorForkSuggestion,
}) => {
isLoggedIn.mockReturnValueOnce(loggedIn);
await createComponent(
{
blob: { ...simpleViewerMock, canModifyBlob, storedExternally: isUsingLfs },
createMergeRequestIn,
forkProject,
},
mount,
);
findBlobHeader().vm.$emit('edit', 'simple');
await nextTick();
expect(findForkSuggestion().exists()).toBe(showSingleFileEditorForkSuggestion);
},
);
it.each`
loggedIn | canModifyBlobWithWebIde | isUsingLfs | createMergeRequestIn | forkProject | showWebIdeForkSuggestion
${true} | ${true} | ${false} | ${true} | ${true} | ${false}
${true} | ${false} | ${false} | ${true} | ${true} | ${true}
${false} | ${false} | ${false} | ${true} | ${true} | ${false}
${true} | ${false} | ${false} | ${false} | ${true} | ${false}
${true} | ${false} | ${false} | ${true} | ${false} | ${false}
${true} | ${false} | ${true} | ${true} | ${true} | ${false}
`(
'shows/hides a fork suggestion for WebIDE according to a set of conditions',
async ({
loggedIn,
canModifyBlobWithWebIde,
isUsingLfs,
createMergeRequestIn,
forkProject,
showWebIdeForkSuggestion,
}) => {
isLoggedIn.mockReturnValueOnce(loggedIn);
await createComponent(
{
blob: { ...simpleViewerMock, canModifyBlobWithWebIde, storedExternally: isUsingLfs },
createMergeRequestIn,
forkProject,
},
mount,
);
findBlobHeader().vm.$emit('edit', 'ide');
await nextTick();
expect(findForkSuggestion().exists()).toBe(showWebIdeForkSuggestion);
},
);
});
describe('active viewer based on plain attribute', () => {

View File

@ -1,42 +0,0 @@
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ForkSuggestion from '~/repository/components/fork_suggestion.vue';
const DEFAULT_PROPS = { forkPath: 'some_file.js/fork' };
describe('ForkSuggestion component', () => {
let wrapper;
const createComponent = () => {
wrapper = shallowMountExtended(ForkSuggestion, {
propsData: { ...DEFAULT_PROPS },
});
};
beforeEach(() => createComponent());
const { i18n } = ForkSuggestion;
const findMessage = () => wrapper.findByTestId('message');
const findForkButton = () => wrapper.findByTestId('fork');
const findCancelButton = () => wrapper.findByTestId('cancel');
it('renders a message', () => {
expect(findMessage().text()).toBe(i18n.message);
});
it('renders a Fork button', () => {
const forkButton = findForkButton();
expect(forkButton.text()).toBe(i18n.fork);
expect(forkButton.attributes('href')).toBe(DEFAULT_PROPS.forkPath);
});
it('renders a Cancel button', () => {
expect(findCancelButton().text()).toBe(i18n.cancel);
});
it('emits a cancel event when Cancel button is clicked', () => {
findCancelButton().vm.$emit('click');
expect(wrapper.emitted('cancel')).toEqual([[]]);
});
});

View File

@ -13,14 +13,12 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { InternalEvents } from '~/tracking';
import WebIdeLink from 'ee_else_ce/vue_shared/components/web_ide_link.vue';
import { resetShortcutsForTests } from '~/behaviors/shortcuts';
import ShortcutsBlob from '~/behaviors/shortcuts/shortcuts_blob';
import Shortcuts from '~/behaviors/shortcuts/shortcuts';
import BlobLinePermalinkUpdater from '~/blob/blob_line_permalink_updater';
import OverflowMenu from 'ee_else_ce/repository/components/header_area/blob_overflow_menu.vue';
import BlobControls from '~/repository/components/header_area/blob_controls.vue';
import blobControlsQuery from '~/repository/queries/blob_controls.query.graphql';
import userGitpodInfo from '~/repository/queries/user_gitpod_info.query.graphql';
import applicationInfoQuery from '~/blob/queries/application_info.query.graphql';
import applicationInfoQuery from '~/repository/queries/application_info.query.graphql';
import createRouter from '~/repository/router';
import OpenMrBadge from '~/repository/components/header_area/open_mr_badge.vue';
import ForkSuggestionModal from '~/repository/components/header_area/fork_suggestion_modal.vue';
@ -33,8 +31,6 @@ import {
Vue.use(VueApollo);
jest.mock('~/repository/utils/dom');
jest.mock('~/behaviors/shortcuts/shortcuts_blob');
jest.mock('~/blob/blob_line_permalink_updater');
jest.mock('~/alert');
jest.mock('~/lib/logger');
jest.mock('~/lib/utils/url_utility', () => ({
@ -89,7 +85,7 @@ describe('Blob controls component', () => {
blobControlsResolver = blobControlsSuccessResolver,
currentUserResolver = currentUserSuccessResolver,
applicationInfoResolver = applicationInfoSuccessResolver,
glFeatures = { blobOverflowMenu: false },
glFeatures = {},
} = {}) => {
const projectPath = 'some/project';
router = createRouter(projectPath, refMock);
@ -133,7 +129,6 @@ describe('Blob controls component', () => {
const findOpenMrBadge = () => wrapper.findComponent(OpenMrBadge);
const findFindButton = () => wrapper.findByTestId('find');
const findBlameButton = () => wrapper.findByTestId('blame');
const findPermalinkButton = () => wrapper.findByTestId('permalink');
const findWebIdeLink = () => wrapper.findComponent(WebIdeLink);
const findForkSuggestionModal = () => wrapper.findComponent(ForkSuggestionModal);
const findOverflowMenu = () => wrapper.findComponent(OverflowMenu);
@ -141,41 +136,13 @@ describe('Blob controls component', () => {
const { bindInternalEventDocument } = useMockInternalEventsTracking();
beforeEach(async () => {
createAlert.mockClear();
await createComponent();
});
afterEach(() => {
fakeApollo = null;
});
it('loads the ShortcutsBlob', () => {
expect(ShortcutsBlob).toHaveBeenCalled();
});
it('loads the BlobLinePermalinkUpdater', () => {
expect(BlobLinePermalinkUpdater).toHaveBeenCalled();
});
describe('Error handling', () => {
it.each`
scenario | resolverParam | loggedError
${'blobControls query'} | ${{ blobControlsResolver: blobControlsErrorResolver }} | ${'Failed to fetch blob controls. See exception details for more information.'}
${'currentUser query'} | ${{ currentUserResolver: currentUserErrorResolver }} | ${'Failed to fetch current user. See exception details for more information.'}
${'applicationInfo query'} | ${{ applicationInfoResolver: applicationInfoErrorResolver }} | ${'Failed to fetch application info. See exception details for more information.'}
`(
'renders an alert and logs the error if the $scenario fails',
async ({ resolverParam, loggedError }) => {
const mockError = new Error('Request failed');
await createComponent(resolverParam);
expect(createAlert).toHaveBeenCalledWith({
message: 'An error occurred while loading the blob controls.',
});
expect(logError).toHaveBeenCalledWith(loggedError, mockError);
expect(Sentry.captureException).toHaveBeenCalledWith(mockError);
},
);
Sentry.captureException.mockRestore();
createAlert.mockClear();
});
describe('MR badge', () => {
@ -192,11 +159,15 @@ describe('Blob controls component', () => {
});
});
describe('FindFile button', () => {
it('renders FindFile button', () => {
describe('Find file button', () => {
it('renders by default', () => {
expect(findFindButton().exists()).toBe(true);
});
it('does not render on mobile layout', () => {
expect(findFindButton().classes()).toContain('gl-hidden', 'sm:gl-inline-flex');
});
it('triggers a `focusSearchFile` shortcut when the findFile button is clicked', () => {
jest.spyOn(Shortcuts, 'focusSearchFile').mockResolvedValue();
@ -224,6 +195,10 @@ describe('Blob controls component', () => {
expect(findBlameButton().attributes('href')).toBe('blame/file.js');
});
it('does not render on mobile layout', () => {
expect(findBlameButton().classes()).toContain('gl-hidden', 'sm:gl-inline-flex');
});
it('does not render blame button when blobInfo.storedExternally is true', async () => {
const blobOverwriteResolver = overrideBlobControlsResolver({ storedExternally: true });
await createComponent({ blobControlsResolver: blobOverwriteResolver });
@ -260,216 +235,199 @@ describe('Blob controls component', () => {
});
});
it('renders a permalink button with the correct href', () => {
expect(findPermalinkButton().attributes('href')).toBe('permalink/file.js');
});
it('does not render WebIdeLink component', () => {
expect(findWebIdeLink().exists()).toBe(false);
});
describe('when blobOverflowMenu feature flag is true', () => {
beforeEach(async () => {
await createComponent({ glFeatures: { blobOverflowMenu: true } });
});
describe('Find file button', () => {
it('does not render on mobile layout', () => {
expect(findFindButton().classes()).toContain('gl-hidden', 'sm:gl-inline-flex');
describe('WebIdeLink component', () => {
it('renders the WebIdeLink component with the correct props', () => {
expect(findWebIdeLink().props()).toMatchObject({
showEditButton: false,
editUrl: 'https://edit/blob/path/file.js',
webIdeUrl: 'https://ide/blob/path/file.js',
needsToFork: false,
needsToForkWithWebIde: false,
showPipelineEditorButton: true,
pipelineEditorUrl: 'pipeline/editor/path/file.yml',
gitpodUrl: 'gitpod/blob/url/file.js',
isGitpodEnabledForInstance: true,
isGitpodEnabledForUser: true,
disabled: false,
});
});
describe('Blame button', () => {
it('does not render on mobile layout', () => {
expect(findBlameButton().classes()).toContain('gl-hidden', 'sm:gl-inline-flex');
it('disables the WebIdeLink component when file is LFS', async () => {
const blobOverwriteResolver = overrideBlobControlsResolver({
storedExternally: true,
externalStorage: 'lfs',
});
await createComponent({
blobControlsResolver: blobOverwriteResolver,
});
expect(findWebIdeLink().props('disabled')).toBe(true);
});
it('does not render WebIdeLink component if file is archived', async () => {
const blobOverwriteResolver = overrideBlobControlsResolver({
...blobControlsDataMock.repository.blobs.nodes[0],
archived: true,
});
await createComponent({
blobControlsResolver: blobOverwriteResolver,
});
expect(findWebIdeLink().exists()).toBe(false);
});
it('does not render WebIdeLink component if file is not editable', async () => {
const blobOverwriteResolver = overrideBlobControlsResolver({
...blobControlsDataMock.repository.blobs.nodes[0],
editBlobPath: '',
});
await createComponent({
blobControlsResolver: blobOverwriteResolver,
});
expect(findWebIdeLink().exists()).toBe(false);
});
describe('when can modify blob', () => {
it('redirects to WebIDE to edit the file', async () => {
findWebIdeLink().vm.$emit('edit', 'ide');
await nextTick();
expect(visitUrl).toHaveBeenCalledWith('https://ide/blob/path/file.js');
expect(findForkSuggestionModal().props('visible')).toBe(false);
});
it('redirects to single file editor to edit the file', async () => {
findWebIdeLink().vm.$emit('edit', 'simple');
await nextTick();
expect(visitUrl).toHaveBeenCalledWith('https://edit/blob/path/file.js');
expect(findForkSuggestionModal().props('visible')).toBe(false);
});
});
describe('WebIdeLink component', () => {
it('renders the WebIdeLink component with the correct props', () => {
expect(findWebIdeLink().props()).toMatchObject({
showEditButton: false,
editUrl: 'https://edit/blob/path/file.js',
webIdeUrl: 'https://ide/blob/path/file.js',
needsToFork: false,
needsToForkWithWebIde: false,
showPipelineEditorButton: true,
pipelineEditorUrl: 'pipeline/editor/path/file.yml',
gitpodUrl: 'gitpod/blob/url/file.js',
isGitpodEnabledForInstance: true,
isGitpodEnabledForUser: true,
disabled: false,
});
});
it('disables the WebIdeLink component when file is LFS', async () => {
const blobOverwriteResolver = overrideBlobControlsResolver({
storedExternally: true,
externalStorage: 'lfs',
});
await createComponent({
blobControlsResolver: blobOverwriteResolver,
glFeatures: { blobOverflowMenu: true },
});
expect(findWebIdeLink().props('disabled')).toBe(true);
});
it('does not render WebIdeLink component if file is archived', async () => {
const blobOverwriteResolver = overrideBlobControlsResolver({
...blobControlsDataMock.repository.blobs.nodes[0],
archived: true,
});
await createComponent({
blobControlsResolver: blobOverwriteResolver,
glFeatures: { blobOverflowMenu: true },
});
expect(findWebIdeLink().exists()).toBe(false);
});
it('does not render WebIdeLink component if file is not editable', async () => {
const blobOverwriteResolver = overrideBlobControlsResolver({
...blobControlsDataMock.repository.blobs.nodes[0],
editBlobPath: '',
});
await createComponent({
blobControlsResolver: blobOverwriteResolver,
glFeatures: { blobOverflowMenu: true },
});
expect(findWebIdeLink().exists()).toBe(false);
});
describe('when can modify blob', () => {
it('redirects to WebIDE to edit the file', async () => {
findWebIdeLink().vm.$emit('edit', 'ide');
await nextTick();
expect(visitUrl).toHaveBeenCalledWith('https://ide/blob/path/file.js');
expect(findForkSuggestionModal().props('visible')).toBe(false);
});
it('redirects to single file editor to edit the file', async () => {
findWebIdeLink().vm.$emit('edit', 'simple');
await nextTick();
expect(visitUrl).toHaveBeenCalledWith('https://edit/blob/path/file.js');
expect(findForkSuggestionModal().props('visible')).toBe(false);
});
});
describe('when user cannot modify blob', () => {
it('changes ForkSuggestionModal visibility', async () => {
const blobControlsForForkResolver = jest.fn().mockResolvedValue({
data: {
project: {
...blobControlsDataMock,
userPermissions: {
...blobControlsDataMock.userPermissions,
pushCode: false,
createMergeRequestIn: true,
},
repository: {
...blobControlsDataMock.repository,
blobs: {
...blobControlsDataMock.repository.blobs,
nodes: [
{
...blobControlsDataMock.repository.blobs.nodes[0],
canModifyBlob: false,
canModifyBlobWithWebIde: false,
},
],
},
describe('when user cannot modify blob', () => {
it('changes ForkSuggestionModal visibility', async () => {
const blobControlsForForkResolver = jest.fn().mockResolvedValue({
data: {
project: {
...blobControlsDataMock,
userPermissions: {
...blobControlsDataMock.userPermissions,
pushCode: false,
createMergeRequestIn: true,
},
repository: {
...blobControlsDataMock.repository,
blobs: {
...blobControlsDataMock.repository.blobs,
nodes: [
{
...blobControlsDataMock.repository.blobs.nodes[0],
canModifyBlob: false,
canModifyBlobWithWebIde: false,
},
],
},
},
},
});
await createComponent({
blobControlsResolver: blobControlsForForkResolver,
glFeatures: { blobOverflowMenu: true },
});
findWebIdeLink().vm.$emit('edit', 'simple');
await nextTick();
expect(findForkSuggestionModal().props('visible')).toBe(true);
});
});
});
describe('ForkSuggestionModal component', () => {
it('renders ForkSuggestionModal', () => {
expect(findForkSuggestionModal().exists()).toBe(true);
expect(findForkSuggestionModal().props()).toMatchObject({
visible: false,
forkPath: 'fork/view/path',
});
});
});
describe('BlobOverflow dropdown', () => {
it('renders a spinner for BlobOverflowMenu when loading repository blob data', async () => {
const loadingBlobControlsResolver = jest.fn().mockResolvedValue(new Promise(() => {}));
await createComponent({
glFeatures: { blobOverflowMenu: true },
blobControlsResolver: loadingBlobControlsResolver,
});
expect(findOverflowMenuLoadingIcon().exists()).toBe(true);
});
it('does not render a spinner for BlobOverflowMenu when not loading repository blob data', () => {
expect(findOverflowMenuLoadingIcon().exists()).toBe(false);
});
it('renders BlobOverflow component with correct props', () => {
expect(findOverflowMenu().exists()).toBe(true);
expect(findOverflowMenu().props()).toEqual({
projectPath: 'some/project',
isBinaryFileType: true,
overrideCopy: true,
isEmptyRepository: false,
isUsingLfs: false,
eeCanCreateLock: undefined,
eeCanDestroyLock: undefined,
eeCanModifyFile: undefined,
eeIsLocked: undefined,
});
});
it('passes the correct isBinary value to BlobOverflow when viewing a binary file', async () => {
await createComponent({
props: {
isBinary: true,
},
glFeatures: {
blobOverflowMenu: true,
},
});
await createComponent({
blobControlsResolver: blobControlsForForkResolver,
});
expect(findOverflowMenu().props('isBinaryFileType')).toBe(true);
});
it('copies to clipboard raw blob text, when receives copy event', () => {
jest.spyOn(navigator.clipboard, 'writeText');
findOverflowMenu().vm.$emit('copy');
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Example raw text content');
});
it('changes ForkSuggestionModal visibility when receives showForkSuggestion event', async () => {
findOverflowMenu().vm.$emit('showForkSuggestion');
findWebIdeLink().vm.$emit('edit', 'simple');
await nextTick();
expect(findForkSuggestionModal().props('visible')).toBe(true);
});
});
});
it('proxy locked-file event', async () => {
findOverflowMenu().vm.$emit('lockedFile', true);
await nextTick();
expect(wrapper.emitted('lockedFile')).toEqual([[true]]);
describe('ForkSuggestionModal component', () => {
it('renders ForkSuggestionModal', () => {
expect(findForkSuggestionModal().exists()).toBe(true);
expect(findForkSuggestionModal().props()).toMatchObject({
visible: false,
forkPath: 'fork/view/path',
});
});
});
describe('BlobOverflow dropdown', () => {
it('renders a spinner for BlobOverflowMenu when loading repository blob data', async () => {
const loadingBlobControlsResolver = jest.fn().mockResolvedValue(new Promise(() => {}));
await createComponent({
blobControlsResolver: loadingBlobControlsResolver,
});
expect(findOverflowMenuLoadingIcon().exists()).toBe(true);
});
it('does not render a spinner for BlobOverflowMenu when not loading repository blob data', () => {
expect(findOverflowMenuLoadingIcon().exists()).toBe(false);
});
it('renders BlobOverflow component with correct props', () => {
expect(findOverflowMenu().exists()).toBe(true);
expect(findOverflowMenu().props()).toMatchObject({
projectPath: 'some/project',
isBinaryFileType: true,
overrideCopy: true,
isEmptyRepository: false,
isUsingLfs: false,
});
});
it('passes the correct isBinary value to BlobOverflow when viewing a binary file', async () => {
await createComponent({
props: {
isBinary: true,
},
});
expect(findOverflowMenu().props('isBinaryFileType')).toBe(true);
});
it('copies to clipboard raw blob text, when receives copy event', () => {
jest.spyOn(navigator.clipboard, 'writeText');
findOverflowMenu().vm.$emit('copy');
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Example raw text content');
});
it('changes ForkSuggestionModal visibility when receives showForkSuggestion event', async () => {
findOverflowMenu().vm.$emit('showForkSuggestion');
await nextTick();
expect(findForkSuggestionModal().props('visible')).toBe(true);
});
it('proxy locked-file event', async () => {
findOverflowMenu().vm.$emit('lockedFile', true);
await nextTick();
expect(wrapper.emitted('lockedFile')).toEqual([[true]]);
});
});
describe('Error handling', () => {
it.each`
scenario | resolverParam | loggedError
${'blobControls query'} | ${{ blobControlsResolver: blobControlsErrorResolver }} | ${'Failed to fetch blob controls. See exception details for more information.'}
${'currentUser query'} | ${{ currentUserResolver: currentUserErrorResolver }} | ${'Failed to fetch current user. See exception details for more information.'}
${'applicationInfo query'} | ${{ applicationInfoResolver: applicationInfoErrorResolver }} | ${'Failed to fetch application info. See exception details for more information.'}
`(
'renders an alert and logs the error if the $scenario fails',
async ({ resolverParam, loggedError }) => {
const mockError = new Error('Request failed');
await createComponent(resolverParam);
expect(createAlert).toHaveBeenCalledWith({
message: 'An error occurred while loading the blob controls.',
});
expect(logError).toHaveBeenCalledWith(loggedError, mockError);
expect(Sentry.captureException).toHaveBeenCalledWith(mockError);
},
);
});
});

View File

@ -56,6 +56,9 @@ describe('Blob Overflow Menu', () => {
blobInfo: blobControlsDataMock.repository.blobs.nodes[0],
currentRef: refMock,
rootRef: 'main',
glFeatures: {
fileLocks: false,
},
...provide,
},
propsData: {

View File

@ -1,7 +1,7 @@
import { nextTick } from 'vue';
import { GlDisclosureDropdownItem } from '@gitlab/ui';
import PermalinkDropdownItem from '~/repository/components/header_area/permalink_dropdown_item.vue';
import { keysFor, PROJECT_FILES_GO_TO_PERMALINK } from '~/behaviors/shortcuts/keybindings';
import { keysFor, PROJECT_FILES_COPY_FILE_PERMALINK } from '~/behaviors/shortcuts/keybindings';
import { shouldDisableShortcuts } from '~/behaviors/shortcuts/shortcuts_toggle';
import { Mousetrap } from '~/lib/mousetrap';
import { hashState, updateHash } from '~/blob/state';
@ -169,12 +169,12 @@ describe('PermalinkDropdownItem', () => {
createComponent();
expect(bindSpy).toHaveBeenCalledWith(
keysFor(PROJECT_FILES_GO_TO_PERMALINK),
keysFor(PROJECT_FILES_COPY_FILE_PERMALINK),
expect.any(Function),
);
wrapper.destroy();
expect(unbindSpy).toHaveBeenCalledWith(keysFor(PROJECT_FILES_GO_TO_PERMALINK));
expect(unbindSpy).toHaveBeenCalledWith(keysFor(PROJECT_FILES_COPY_FILE_PERMALINK));
});
it('add and remove event listener for hashChange event', () => {
@ -193,7 +193,7 @@ describe('PermalinkDropdownItem', () => {
shouldDisableShortcuts.mockReturnValue(false);
createComponent();
expect(wrapper.find('kbd').exists()).toBe(true);
expect(wrapper.find('kbd').text()).toBe(keysFor(PROJECT_FILES_GO_TO_PERMALINK)[0]);
expect(wrapper.find('kbd').text()).toBe(keysFor(PROJECT_FILES_COPY_FILE_PERMALINK)[0]);
});
it('does not display the shortcut key when shortcuts are disabled', () => {

View File

@ -123,26 +123,26 @@ describe('WorkItemChildrenWrapper', () => {
expect(workItemLinkChildren).toHaveLength(3);
});
it('emits `show-modal` on `click` event', () => {
it('emits `show-modal` on `toggleDrawer` event', () => {
createComponent();
const event = {
childItem: 'gid://gitlab/WorkItem/2',
stopPropagation: jest.fn(),
};
findFirstWorkItemLinkChildItem().vm.$emit('click', event);
findFirstWorkItemLinkChildItem().vm.$emit('toggleDrawer', event);
expect(wrapper.emitted('show-modal')).toEqual([[{ event, child: event.childItem }]]);
});
it('emits `click` event when clicking on nested child', () => {
it('emits `toggleDrawer` event when clicking on nested child', () => {
createComponent({ isTopLevel: false });
const event = {
childItem: 'gid://gitlab/WorkItem/2',
stopPropagation: jest.fn(),
};
findFirstWorkItemLinkChildItem().vm.$emit('click', event);
findFirstWorkItemLinkChildItem().vm.$emit('toggleDrawer', event);
expect(wrapper.emitted('click')).toEqual([[event]]);
});

View File

@ -206,7 +206,7 @@ describe('WorkItemLinkChild', () => {
findTreeChildren().vm.$emit('click', 'event');
expect(wrapper.emitted('click')).toEqual([['event']]);
expect(wrapper.emitted('toggleDrawer')).toEqual([['event']]);
});
it('emits event on removing child item', () => {

View File

@ -2,18 +2,11 @@
require 'spec_helper'
RSpec.describe Gitlab::BackgroundMigration::BackfillArchivedAndTraversalIdsToVulnerabilityStatistics, migration: :gitlab_sec, feature_category: :vulnerability_management do
before(:all) do
# Some spec in this file currently fails when a sec database is configured. We plan to ensure it all functions
# and passes prior to the sec db rollout.
# Consult https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180764 for more info.
skip_if_multiple_databases_are_setup(:sec)
end
RSpec.describe Gitlab::BackgroundMigration::BackfillArchivedAndTraversalIdsToVulnerabilityStatistics, feature_category: :vulnerability_management do
let(:organizations) { table(:organizations) }
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
let(:vulnerability_statistics) { table(:vulnerability_statistics) }
let(:vulnerability_statistics) { table(:vulnerability_statistics, database: :sec) }
let(:args) do
min, max = vulnerability_statistics.pick('MIN(id)', 'MAX(id)')
{

View File

@ -123,9 +123,10 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillDetectedAtFromCreatedAtColum
end
before(:all) do
# This test shares the db connection to establish it's fixtures, resulting in
# incorrect connection usage, so we're skipping it.
# Consult https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180764 for more info.
# This spec was disabled before the confidence column was dropped from
# vulnerabilities. https://gitlab.com/gitlab-org/gitlab/-/merge_requests/182637/diffs
# As a result, the BG migration can't work and the spec can't pass either, so no
# point fixing it. We'll wait for it to be cleaned up with the migration.
skip_if_multiple_databases_are_setup(:sec)
end

View File

@ -0,0 +1,168 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::BackgroundMigration::BackfillPlaceholderUsersDetailsFromSourceUsers, feature_category: :importers do
let(:migration_attrs) do
{
start_id: import_source_users_table.minimum(:id),
end_id: import_source_users_table.maximum(:id),
batch_table: :import_source_users,
batch_column: :id,
sub_batch_size: 2,
pause_ms: 0,
connection: connection
}
end
let(:migration) { described_class.new(**migration_attrs) }
let(:connection) { ApplicationRecord.connection }
let(:import_source_users_table) { table(:import_source_users) }
let(:import_placeholder_user_details_table) { table(:import_placeholder_user_details) }
let(:users_table) { table(:users) }
let(:namespaces_table) { table(:namespaces) }
let(:organizations_table) { table(:organizations) }
let(:organization) { create_organization }
let(:namespace) { create_namespace(organization_id: organization.id) }
let(:user) { create_user(email: "test1@example.com", username: "test1") }
let(:source_user_without_placeholder) { create_source_user(placeholder_user_id: nil, namespace_id: namespace.id) }
let(:existing_user) { create_user(email: "test2@example.com", username: "test2") }
let(:source_user_with_existing_detail) do
create_source_user(placeholder_user_id: existing_user.id, namespace_id: namespace.id)
end
let!(:existing_detail) do
create_placeholder_user_detail(placeholder_user_id: existing_user.id, namespace_id: namespace.id,
organization_id: organization.id)
end
describe '#perform' do
it 'creates placeholder user details for source users without them', :aggregate_failures do
create_source_user(placeholder_user_id: user.id, namespace_id: namespace.id)
expect do
migration.perform
end.to change { count_placeholder_user_details }.by(1)
detail = find_placeholder_user_detail(user.id)
expect(detail).to be_present
expect(detail['namespace_id']).to eq(namespace.id)
expect(detail['organization_id']).to eq(organization.id)
end
it 'skips source users without placeholder_user_id' do
create_source_user(placeholder_user_id: nil, namespace_id: namespace.id)
expect do
migration.perform
end.not_to change { count_placeholder_user_details }
end
it 'skips source users that already have placeholder user details' do
create_source_user(placeholder_user_id: existing_user.id, namespace_id: namespace.id)
expect do
migration.perform
end.not_to change { count_placeholder_user_details(existing_user.id) }
end
it 'logs error and re-raises exception when bulk insert fails', :aggregate_failures do
create_source_user(placeholder_user_id: user.id, namespace_id: namespace.id)
logger = instance_double(Gitlab::BackgroundMigration::Logger)
allow(Gitlab::BackgroundMigration::Logger).to receive(:build).and_return(logger)
exception = StandardError.new("Database connection error")
allow(Gitlab::BackgroundMigration::BackfillPlaceholderUsersDetailsFromSourceUsers::ImportPlaceholderUserDetail)
.to receive(:upsert_all).and_raise(exception)
expect(logger).to receive(:error).with(
hash_including(
message: "Error bulk creating placeholder user details: Database connection error",
count: 1,
placeholder_user_ids: [user.id]
)
)
expect { migration.perform }.to raise_error(StandardError, "Database connection error")
end
it 'processes multiple batches correctly', :aggregate_failures do
user3 = create_user(email: "test3@example.com", username: "test3")
user4 = create_user(email: "test4@example.com", username: "test4")
user5 = create_user(email: "test5@example.com", username: "test5")
create_source_user(placeholder_user_id: user.id, namespace_id: namespace.id)
create_source_user(placeholder_user_id: user3.id, namespace_id: namespace.id)
create_source_user(placeholder_user_id: user4.id, namespace_id: namespace.id)
create_source_user(placeholder_user_id: user5.id, namespace_id: namespace.id)
create_placeholder_user_detail(placeholder_user_id: user4.id, namespace_id: namespace.id,
organization_id: organization.id)
create_source_user(placeholder_user_id: nil, namespace_id: namespace.id)
expect do
migration.perform
end.to change { count_placeholder_user_details }.by(3)
end
end
def create_organization
organizations_table.create!(
name: 'GitLab Enterprise',
path: 'gitlab-enterprise'
)
end
def create_namespace(organization_id:)
namespaces_table.create!(
name: 'Engineering Department',
path: 'engineering-dept',
organization_id: organization_id
)
end
def create_user(overrides = {})
attrs = {
email: "test@example.com",
notification_email: "test@example.com",
name: "test",
username: "test",
state: "active",
projects_limit: 10
}.merge(overrides)
users_table.create!(attrs)
end
def create_source_user(placeholder_user_id:, namespace_id:)
import_source_users_table.create!(
placeholder_user_id: placeholder_user_id,
namespace_id: namespace_id,
source_user_identifier: SecureRandom.uuid,
source_hostname: 'https://gitlab.com',
source_name: 'test-user',
source_username: 'test-user1',
import_type: 'gitlab_migration'
)
end
def create_placeholder_user_detail(placeholder_user_id:, namespace_id:, organization_id:)
import_placeholder_user_details_table.create!(
placeholder_user_id: placeholder_user_id,
namespace_id: namespace_id,
organization_id: organization_id
)
end
def count_placeholder_user_details(placeholder_user_id = nil)
query = import_placeholder_user_details_table
query = query.where(placeholder_user_id: placeholder_user_id) if placeholder_user_id
query.count
end
def find_placeholder_user_detail(placeholder_user_id)
Import::PlaceholderUserDetail.find_by(placeholder_user_id: placeholder_user_id)
end
end

View File

@ -28,9 +28,11 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillProjectIdToDependencyListExp
subject(:perform_migration) { described_class.new(**args).perform }
before do
# This test shares the db connection to establish it's fixtures, resulting in
# incorrect connection usage, so we're skipping it.
# Consult https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180764 for more info.
# With the introduction of:
# CONSTRAINT check_67a9c23e79 CHECK ((num_nonnulls(group_id, organization_id, project_id) > 0))
# the table now has a sharding key. As this bbm was finalised in
# migration 20241106181154, making the spec pass isn't worht the effort.
skip_if_multiple_databases_are_setup(:sec)
end

View File

@ -28,9 +28,10 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillProjectIdToSecurityScans, fe
subject(:perform_migration) { described_class.new(**args).perform }
before do
# This test shares the db connection to establish it's fixtures, resulting in
# incorrect connection usage, so we're skipping it.
# Consult https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180764 for more info.
# With the introduction of CONSTRAINT check_2d56d882f6 CHECK ((project_id IS NOT NULL))
# it's no longer possible for project_id to be null, meaning this spec is now inapplicable
# and waiting for the cleanup of this BG migration.
skip_if_multiple_databases_are_setup(:sec)
end

View File

@ -4,14 +4,8 @@ require 'spec_helper'
RSpec.describe Gitlab::BackgroundMigration::BackfillVulnerabilityExternalIssueLinksProjectId,
feature_category: :vulnerability_management,
schema: 20240624135059 do
before do
# This test shares the db connection to establish it's fixtures, resulting in
# incorrect connection usage, so we're skipping it.
# Consult https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180764 for more info.
skip_if_multiple_databases_are_setup(:sec)
end
schema: 20240624135059,
migration: :gitlab_sec do
include_examples 'desired sharding key backfill job' do
let(:batch_table) { :vulnerability_external_issue_links }
let(:backfill_column) { :project_id }

View File

@ -4,14 +4,8 @@ require 'spec_helper'
RSpec.describe Gitlab::BackgroundMigration::BackfillVulnerabilityOccurrenceIdentifiersProjectId,
feature_category: :vulnerability_management,
schema: 20240730171958 do
before do
# This test shares the db connection to establish it's fixtures, resulting in
# incorrect connection usage, so we're skipping it.
# Consult https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180764 for more info.
skip_if_multiple_databases_are_setup(:sec)
end
schema: 20240730171958,
migration: :gitlab_sec do
include_examples 'desired sharding key backfill job' do
let(:batch_table) { :vulnerability_occurrence_identifiers }
let(:backfill_column) { :project_id }

View File

@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Gitlab::BackgroundMigration::RemoveNamespaceFromOsTypeSbomComponents, schema: 20240909204952, feature_category: :software_composition_analysis do
let(:components) { table(:sbom_components) }
let(:components) { table(:sbom_components, database: :sec) }
let(:expected) do
(0...os_prefix_to_purl_type_mapping.size).map { |n| "package-#{n}" }
end
@ -33,11 +33,6 @@ RSpec.describe Gitlab::BackgroundMigration::RemoveNamespaceFromOsTypeSbomCompone
end
before do
# This test shares the db connection to establish it's fixtures, resulting in
# incorrect connection usage, so we're skipping it.
# Consult https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180764 for more info.
skip_if_multiple_databases_are_setup(:sec)
os_prefix_to_purl_type_mapping.each.with_index do |(namespace, purl_type), index|
components.create!(name: "#{namespace}/package-#{index}", purl_type: purl_type, component_type: 0)
end

View File

@ -4,10 +4,10 @@ require 'spec_helper'
RSpec.describe Gitlab::BackgroundMigration::ResyncHasVulnerabilities, feature_category: :vulnerability_management do
let(:project_settings) { table(:project_settings) }
let(:findings) { table(:vulnerability_occurrences) }
let(:vulnerabilities) { table(:vulnerabilities) }
let(:identifiers) { table(:vulnerability_identifiers) }
let(:scanners) { table(:vulnerability_scanners) }
let(:findings) { table(:vulnerability_occurrences, database: :sec) }
let(:vulnerabilities) { table(:vulnerabilities, database: :sec) }
let(:identifiers) { table(:vulnerability_identifiers, database: :sec) }
let(:scanners) { table(:vulnerability_scanners, database: :sec) }
let!(:user) { table(:users).create!(email: 'author@example.com', username: 'author', projects_limit: 10) }
let(:organizations) { table(:organizations) }
let(:namespaces) { table(:namespaces) }
@ -68,13 +68,6 @@ RSpec.describe Gitlab::BackgroundMigration::ResyncHasVulnerabilities, feature_ca
subject(:perform_migration) { described_class.new(**args).perform }
before do
# This test shares the db connection to establish it's fixtures, resulting in
# incorrect connection usage, so we're skipping it.
# Consult https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180764 for more info.
skip_if_multiple_databases_are_setup(:sec)
end
def create_project_setting(
name,
has_vulnerabilities_setting:,
@ -115,7 +108,6 @@ RSpec.describe Gitlab::BackgroundMigration::ResyncHasVulnerabilities, feature_ca
finding = findings.create!(
scanner_id: scanner.id,
primary_identifier_id: primary_identifier.id,
project_fingerprint: SecureRandom.hex(20),
location_fingerprint: SecureRandom.hex(20),
uuid: SecureRandom.uuid,
name: 'name',

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
require 'spec_helper'
require_migration!
RSpec.describe QueueBackfillPlaceholderUsersDetailsFromSourceUsers, migration: :gitlab_main, feature_category: :importers do
let(:batched_migration) { described_class::MIGRATION }
it 'schedules a new batched migration' do
reversible_migration do |migration|
migration.before -> {
expect(batched_migration).not_to have_scheduled_batched_migration
}
migration.after -> {
expect(batched_migration).to have_scheduled_batched_migration(
gitlab_schema: :gitlab_main,
table_name: :import_source_users,
column_name: :id,
interval: described_class::DELAY_INTERVAL,
batch_size: described_class::BATCH_SIZE,
sub_batch_size: described_class::SUB_BATCH_SIZE
)
}
end
end
end

View File

@ -0,0 +1,130 @@
# frozen_string_literal: true
require 'spec_helper'
require_migration!
RSpec.describe RemoveCustomerRelationContactsForEpicType, migration: :gitlab_main, feature_category: :service_desk do
let(:organizations) { table(:organizations) }
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
let(:issues) { table(:issues) }
let(:work_item_types) { table(:work_item_types) }
let(:customer_relations_contacts) { table(:customer_relations_contacts) }
let(:issue_customer_relations_contacts) { table(:issue_customer_relations_contacts) }
let(:epic_work_item_type_id) { described_class::EPIC_WORK_ITEM_TYPE_ID }
let!(:organization) { organizations.create!(name: 'Test Org', path: 'test-org') }
let!(:namespace) { namespaces.create!(name: 'Test Group', path: 'test-group', organization_id: organization.id) }
let!(:project) do
projects.create!(name: 'Test Project', path: 'test-project', namespace_id: namespace.id,
project_namespace_id: namespace.id, organization_id: organization.id)
end
before do
stub_const("#{described_class}::BATCH_SIZE", 2)
end
describe '#up' do
let!(:epic_issue) do
issues.create!(
title: 'Epic Issue',
project_id: project.id,
namespace_id: namespace.id,
work_item_type_id: epic_work_item_type_id
)
end
let!(:regular_issue) do
issues.create!(
title: 'Regular Issue',
project_id: project.id,
namespace_id: namespace.id,
work_item_type_id: 1
)
end
let!(:epic_contacts) do
[
customer_relations_contacts.create!(group_id: namespace.id, first_name: 'Epic', last_name: 'Contact1',
email: 'epic1@example.com'),
customer_relations_contacts.create!(group_id: namespace.id, first_name: 'Epic', last_name: 'Contact2',
email: 'epic2@example.com'),
customer_relations_contacts.create!(group_id: namespace.id, first_name: 'Epic', last_name: 'Contact3',
email: 'epic3@example.com')
]
end
let!(:regular_contact) do
customer_relations_contacts.create!(group_id: namespace.id, first_name: 'Regular', last_name: 'Contact',
email: 'regular@example.com')
end
before do
epic_contacts.each do |contact|
issue_customer_relations_contacts.create!(
issue_id: epic_issue.id,
contact_id: contact.id,
namespace_id: namespace.id
)
end
issue_customer_relations_contacts.create!(
issue_id: regular_issue.id,
contact_id: regular_contact.id,
namespace_id: namespace.id
)
end
it 'removes issue_customer_relations_contacts associated with epic work item type in batches' do
expect do
migrate!
end.to make_queries_matching(
/DELETE FROM issue_customer_relations_contacts/,
2 # Should run 2 batches (3 epic contacts + 1 regular contact, batch size = 2)
)
end
it 'removes only issue_customer_relations_contacts associated with epic work item type' do
expect { migrate! }.to change { issue_customer_relations_contacts.count }.from(4).to(1)
.and not_change { customer_relations_contacts.count }
end
it 'keeps issue_customer_relations_contacts for non-epic work item types' do
expect { migrate! }.to change {
issue_customer_relations_contacts.where(issue_id: epic_issue.id).count
}.from(3).to(0)
.and not_change { issue_customer_relations_contacts.where(issue_id: regular_issue.id).count }
end
context 'when there are no epic-related contacts' do
before do
customer_relations_contacts.delete_all
issue_customer_relations_contacts.delete_all
regular_contact_new = customer_relations_contacts.create!(
group_id: namespace.id,
first_name: 'Regular',
last_name: 'Contact',
email: 'regular@example.com'
)
issue_customer_relations_contacts.create!(
issue_id: regular_issue.id,
contact_id: regular_contact_new.id,
namespace_id: namespace.id
)
end
it 'does not remove any issue_customer_relations_contacts' do
expect { migrate! }.not_to change { issue_customer_relations_contacts.count }
end
end
end
describe '#down' do
it 'is a no-op' do
expect { schema_migrate_down! }.not_to change { issue_customer_relations_contacts.count }
end
end
end

View File

@ -3038,10 +3038,8 @@
- './spec/features/projects/badges/list_spec.rb'
- './spec/features/projects/badges/pipeline_badge_spec.rb'
- './spec/features/projects/blobs/blame_spec.rb'
- './spec/features/projects/blobs/blob_line_permalink_updater_spec.rb'
- './spec/features/projects/blobs/blob_show_spec.rb'
- './spec/features/projects/blobs/edit_spec.rb'
- './spec/features/projects/blobs/shortcuts_blob_spec.rb'
- './spec/features/projects/blobs/user_views_pipeline_editor_button_spec.rb'
- './spec/features/projects/branches/download_buttons_spec.rb'
- './spec/features/projects/branches/new_branch_ref_dropdown_spec.rb'