Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2025-01-30 18:15:13 +00:00
parent 55583893ca
commit 08e8c5f723
84 changed files with 1299 additions and 251 deletions

View File

@ -14,7 +14,7 @@ variables:
NODE_VERSION: "20.12"
OS_VERSION: "bookworm"
RUBY_VERSION_DEFAULT: "3.2.6"
RUBY_VERSION_NEXT: "3.3.6"
RUBY_VERSION_NEXT: "3.3.7"
RUBYGEMS_VERSION: "3.6"
RUST_VERSION: "1.73"
UBI_VERSION: "9.5"

View File

@ -70,6 +70,7 @@ After your merge request has been approved according to our [approval guidelines
|-------------------------------------|------------|-----------------------------------------------------------|
| Version affected | X.Y | |
| Date introduced on .com | YYYY-MM-DD | #TODO for Engineering - please follow the format |
| MR that introduced the bug | | #TODO for Engineering - Link to the MR that introduced the bug|
| Date detected | YYYY-MM-DD | #TODO for AppSec - please follow the format |
| GitLab EE only | Yes/No | |
| Upgrade notes | | |

View File

@ -51,7 +51,7 @@ export const formatPipelinesGraphQLDataToREST = (project) => {
stuck: pipeline.stuck,
auto_devops: pipeline.configSource === SOURCE_AUTO_DEVOPS,
merge_request: true,
yaml_errors: pipeline.yamlErrors,
yaml_errors: Boolean(pipeline.errorMessages?.nodes?.length),
retryable: pipeline.retryable,
cancelable: pipeline.cancelable,
failure_reason: pipeline.failureReason,

View File

@ -94,6 +94,7 @@ export default {
{
key: 'name',
label: this.$options.i18n.nameLabel,
isRowHeader: true,
tdClass,
thClass,
},
@ -260,7 +261,9 @@ export default {
data-testid="cluster-agent-list-table"
>
<template #cell(name)="{ item }">
<div class="gl-flex gl-flex-wrap gl-justify-end gl-gap-3 md:gl-justify-start">
<div
class="gl-flex gl-flex-wrap gl-justify-end gl-gap-3 gl-font-normal md:gl-justify-start"
>
<gl-link :href="item.webPath" data-testid="cluster-agent-name-link">{{
item.name
}}</gl-link

View File

@ -49,6 +49,7 @@ export default {
{
key: 'configuration',
label: this.$options.i18n.configurationLabel,
isRowHeader: true,
tdClass: `${tdClass} md:gl-w-4/5`,
thClass,
},
@ -96,7 +97,7 @@ export default {
class="!gl-mb-4"
>
<template #cell(configuration)="{ item }">
<gl-link :href="item.webPath">{{ item.path }}</gl-link>
<gl-link class="gl-font-normal" :href="item.webPath">{{ item.path }}</gl-link>
</template>
<template #cell(actions)="{ item }">

View File

@ -76,6 +76,7 @@ export default {
{
key: 'name',
label: __('Kubernetes cluster'),
isRowHeader: true,
tdClass,
},
{
@ -252,7 +253,7 @@ export default {
class="gl-flex gl-h-6 gl-w-6 gl-items-center"
/>
<gl-link :href="item.path" class="gl-px-3">
<gl-link :href="item.path" class="gl-px-3 gl-font-normal">
{{ item.name }}
</gl-link>

View File

@ -7,6 +7,7 @@ import {
GlDisclosureDropdown,
GlBadge,
GlLink,
GlPopover,
} from '@gitlab/ui';
import { localeDateFormat, newDate } from '~/lib/utils/datetime_utility';
import { numberToHumanSize } from '~/lib/utils/number_utils';
@ -15,6 +16,7 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
REMOVE_TAG_BUTTON_TITLE,
DIGEST_LABEL,
@ -47,10 +49,12 @@ export default {
TimeAgoTooltip,
DetailsRow,
SignatureDetailsModal,
GlPopover,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagsMixin()],
props: {
tag: {
type: Object,
@ -157,6 +161,22 @@ export default {
isDockerOrOciMediaType() {
return this.tag.mediaType === DOCKER_MEDIA_TYPE || this.tag.mediaType === OCI_MEDIA_TYPE;
},
isProtected() {
return (
(this.tag.protection?.minimumAccessLevelForDelete != null ||
this.tag.protection?.minimumAccessLevelForPush != null) &&
this.glFeatures.containerRegistryProtectedTags
);
},
tagRowId() {
return `${this.tag.name}_badge`;
},
accessLevelForDelete() {
return this.tag.protection?.minimumAccessLevelForDelete;
},
accessLevelForPush() {
return this.tag.protection?.minimumAccessLevelForPush;
},
},
};
</script>
@ -183,6 +203,26 @@ export default {
{{ tag.name }}
</div>
<template v-if="isProtected">
<gl-badge
:id="tagRowId"
boundary="viewport"
class="gl-ml-4"
data-testid="protected-badge"
>
{{ __('protected') }}
</gl-badge>
<gl-popover :target="tagRowId" data-testid="protected-popover">
<strong>{{ s__('ContainerRegistry|This tag is protected') }}</strong>
<br />
<br />
<strong>{{ s__('ContainerRegistry|Minimum role to push: ') }}</strong>
{{ accessLevelForPush }}
<strong>{{ s__('ContainerRegistry|Minimum role to delete: ') }}</strong>
{{ accessLevelForDelete }}
</gl-popover>
</template>
<clipboard-button
v-if="tag.location"
:title="$options.i18n.COPY_IMAGE_PATH_TITLE"
@ -204,7 +244,11 @@ export default {
</template>
<template v-if="signatures.length" #left-after-toggle>
<gl-badge v-gl-tooltip.d0="$options.i18n.SIGNATURE_BADGE_TOOLTIP" class="gl-ml-4">
<gl-badge
v-gl-tooltip.d0="$options.i18n.SIGNATURE_BADGE_TOOLTIP"
class="gl-ml-4"
data-testid="signed-badge"
>
{{ s__('ContainerRegistry|Signed') }}
</gl-badge>
</template>

View File

@ -39,6 +39,10 @@ query getContainerRepositoryTags(
userPermissions {
destroyContainerRepositoryTag
}
protection {
minimumAccessLevelForPush
minimumAccessLevelForDelete
}
referrers {
artifactType
digest

View File

@ -10,11 +10,12 @@ import {
GlSprintf,
} from '@gitlab/ui';
import createProtectionTagRuleMutation from '~/packages_and_registries/settings/project/graphql/mutations/create_container_protection_tag_rule.mutation.graphql';
import updateProtectionTagRuleMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_container_protection_tag_rule.mutation.graphql';
import {
MinimumAccessLevelOptions,
GRAPHQL_ACCESS_LEVEL_VALUE_MAINTAINER,
} from '~/packages_and_registries/settings/project/constants';
import { s__ } from '~/locale';
import { __, s__ } from '~/locale';
import { InternalEvents } from '~/tracking';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
@ -31,13 +32,22 @@ export default {
},
mixins: [InternalEvents.mixin()],
inject: ['projectPath'],
props: {
rule: {
type: Object,
required: false,
default: null,
},
},
data() {
return {
alertErrorMessages: [],
protectionRuleFormData: {
tagNamePattern: '',
minimumAccessLevelForPush: GRAPHQL_ACCESS_LEVEL_VALUE_MAINTAINER,
minimumAccessLevelForDelete: GRAPHQL_ACCESS_LEVEL_VALUE_MAINTAINER,
tagNamePattern: this.rule?.tagNamePattern ?? '',
minimumAccessLevelForPush:
this.rule?.minimumAccessLevelForPush ?? GRAPHQL_ACCESS_LEVEL_VALUE_MAINTAINER,
minimumAccessLevelForDelete:
this.rule?.minimumAccessLevelForDelete ?? GRAPHQL_ACCESS_LEVEL_VALUE_MAINTAINER,
},
showValidation: false,
updateInProgress: false,
@ -47,9 +57,7 @@ export default {
createProtectionRuleMutationInput() {
return {
projectPath: this.projectPath,
tagNamePattern: this.protectionRuleFormData.tagNamePattern,
minimumAccessLevelForPush: this.protectionRuleFormData.minimumAccessLevelForPush,
minimumAccessLevelForDelete: this.protectionRuleFormData.minimumAccessLevelForDelete,
...this.protectionRuleFormData,
};
},
isTagNamePatternValid() {
@ -64,9 +72,24 @@ export default {
}
return s__('ContainerRegistry|This field is required.');
},
mutation() {
return this.rule ? updateProtectionTagRuleMutation : createProtectionTagRuleMutation;
},
mutationKey() {
return this.rule ? 'updateContainerProtectionTagRule' : 'createContainerProtectionTagRule';
},
tagNamePattern() {
return this.protectionRuleFormData.tagNamePattern;
},
submitButtonText() {
return this.rule ? __('Save changes') : s__('ContainerRegistry|Add rule');
},
updateProtectionTagRuleMutationInput() {
return {
id: this.rule?.id,
...this.protectionRuleFormData,
};
},
},
methods: {
submit() {
@ -76,16 +99,19 @@ export default {
this.clearAlertErrorMessages();
this.updateInProgress = true;
const input = this.rule
? this.updateProtectionTagRuleMutationInput
: this.createProtectionRuleMutationInput;
this.$apollo
.mutate({
mutation: createProtectionTagRuleMutation,
mutation: this.mutation,
variables: {
input: this.createProtectionRuleMutationInput,
input,
},
})
.then(({ data }) => {
const errorMessages = data?.createContainerProtectionTagRule?.errors ?? [];
const errorMessages = data?.[this.mutationKey]?.errors ?? [];
if (errorMessages?.length) {
this.alertErrorMessages = Array.isArray(errorMessages)
? errorMessages
@ -93,8 +119,12 @@ export default {
return;
}
this.$emit('submit', data.createContainerProtectionTagRule.containerProtectionTagRule);
this.trackEvent('container_protection_tag_rule_created');
this.$emit('submit', data[this.mutationKey].containerProtectionTagRule);
if (this.rule) {
this.trackEvent('container_protection_tag_rule_created');
} else {
this.trackEvent('container_protection_tag_rule_updated');
}
})
.catch((error) => {
this.handleError(error);
@ -174,10 +204,10 @@ export default {
<gl-form-group
:label="s__('ContainerRegistry|Minimum role allowed to push')"
label-for="input-minimum-access-level-for-push"
label-for="select-minimum-access-level-for-push"
>
<gl-form-select
id="input-minimum-access-level-for-push"
id="select-minimum-access-level-for-push"
v-model="protectionRuleFormData.minimumAccessLevelForPush"
:options="$options.minimumAccessLevelOptions"
/>
@ -192,12 +222,13 @@ export default {
<gl-form-group
:label="s__('ContainerRegistry|Minimum role allowed to delete')"
label-for="input-minimum-access-level-for-delete"
label-for="select-minimum-access-level-for-delete"
>
<gl-form-select
id="input-minimum-access-level-for-delete"
id="select-minimum-access-level-for-delete"
v-model="protectionRuleFormData.minimumAccessLevelForDelete"
:options="$options.minimumAccessLevelOptions"
data-testid="select-minimum-access-level-for-delete"
/>
<template #description>
{{
@ -213,9 +244,9 @@ export default {
class="js-no-auto-disable"
variant="confirm"
type="submit"
data-testid="add-rule-btn"
data-testid="submit-btn"
:loading="updateInProgress"
>{{ s__('ContainerRegistry|Add rule') }}</gl-button
>{{ submitButtonText }}</gl-button
>
<gl-button type="reset">{{ __('Cancel') }}</gl-button>
</div>

View File

@ -77,12 +77,18 @@ export default {
protectionRulesQueryPayload: { nodes: [], pageInfo: {} },
protectionRulesQueryPaginationParams: { first: MAX_LIMIT },
showDrawer: false,
showModal: false,
};
},
computed: {
containsTableItems() {
return this.tagProtectionRulesCount > 0;
},
drawerTitle() {
return this.protectionRuleMutationItem
? s__('ContainerRegistry|Edit protection rule')
: s__('ContainerRegistry|Add protection rule');
},
isLoadingProtectionRules() {
return this.$apollo.queries.protectionRulesQueryPayload.loading;
},
@ -92,6 +98,9 @@ export default {
rulesLimitReached() {
return this.tagProtectionRulesCount === MAX_LIMIT;
},
mutationItemTagNamePattern() {
return this.protectionRuleMutationItem?.tagNamePattern ?? '';
},
showTableLoading() {
return this.protectionRuleMutationInProgress || this.isLoadingProtectionRules;
},
@ -99,10 +108,8 @@ export default {
return this.protectionRulesQueryResult.map((protectionRule) => {
return {
id: protectionRule.id,
minimumAccessLevelForPush:
MinimumAccessLevelText[protectionRule.minimumAccessLevelForPush],
minimumAccessLevelForDelete:
MinimumAccessLevelText[protectionRule.minimumAccessLevelForDelete],
minimumAccessLevelForPush: protectionRule.minimumAccessLevelForPush,
minimumAccessLevelForDelete: protectionRule.minimumAccessLevelForDelete,
tagNamePattern: protectionRule.tagNamePattern,
};
});
@ -116,6 +123,11 @@ export default {
}
return s__('ContainerRegistry|Add protection rule');
},
toastMessage() {
return this.protectionRuleMutationItem
? s__('ContainerRegistry|Container protection rule updated.')
: s__('ContainerRegistry|Container protection rule created.');
},
},
methods: {
clearAlertMessage() {
@ -149,12 +161,20 @@ export default {
this.resetProtectionRuleMutation();
}
},
formatAccessLevel(level) {
return MinimumAccessLevelText[level];
},
handleSubmit() {
this.$toast.show(s__('ContainerRegistry|Container protection rule created.'));
this.$toast.show(this.toastMessage);
this.closeDrawer();
this.refetchProtectionRules();
},
openDrawer() {
openEditFormDrawer(item) {
this.protectionRuleMutationItem = item;
this.showDrawer = true;
},
openNewFormDrawer() {
this.protectionRuleMutationItem = null;
this.showDrawer = true;
},
refetchProtectionRules() {
@ -166,6 +186,7 @@ export default {
},
showProtectionRuleDeletionConfirmModal(protectionRule) {
this.protectionRuleMutationItem = protectionRule;
this.showModal = true;
},
},
fields: [
@ -193,6 +214,7 @@ export default {
],
i18n: {
deleteIconButton: __('Delete'),
editIconButton: __('Edit'),
title: s__('ContainerRegistry|Protected container image tags'),
protectionRuleDeletionConfirmModal: {
title: s__('ContainerRegistry|Delete protection rule'),
@ -221,7 +243,7 @@ export default {
:title="$options.i18n.title"
:toggle-text="toggleText"
data-testid="project-container-protection-tag-rules-settings"
@showForm="openDrawer"
@showForm="openNewFormDrawer"
>
<template v-if="containsTableItems" #count>
<gl-badge>
@ -284,7 +306,27 @@ export default {
<gl-loading-icon size="sm" class="gl-my-5" data-testid="table-loading-icon" />
</template>
<template #cell(minimumAccessLevelForPush)="{ item }">
<span data-testid="minimum-access-level-push-value">
{{ formatAccessLevel(item.minimumAccessLevelForPush) }}
</span>
</template>
<template #cell(minimumAccessLevelForDelete)="{ item }">
<span data-testid="minimum-access-level-delete-value">
{{ formatAccessLevel(item.minimumAccessLevelForDelete) }}
</span>
</template>
<template #cell(rowActions)="{ item }">
<gl-button
v-gl-tooltip
category="tertiary"
icon="pencil"
:title="$options.i18n.editIconButton"
:aria-label="$options.i18n.editIconButton"
@click="openEditFormDrawer(item)"
/>
<gl-button
v-gl-tooltip
v-gl-modal="$options.modal.id"
@ -300,19 +342,23 @@ export default {
{{ s__('ContainerRegistry|No container image tags are protected.') }}
</p>
<gl-drawer :z-index="1400" :open="showDrawer" @close="closeDrawer">
<gl-drawer :z-index="1039" :open="showDrawer" @close="closeDrawer">
<template #title>
<h2 class="gl-my-0 gl-text-size-h2 gl-leading-24">
{{ s__('ContainerRegistry|Add protection rule') }}
{{ drawerTitle }}
</h2>
</template>
<template #default>
<container-protection-tag-rule-form @cancel="closeDrawer" @submit="handleSubmit" />
<container-protection-tag-rule-form
:rule="protectionRuleMutationItem"
@cancel="closeDrawer"
@submit="handleSubmit"
/>
</template>
</gl-drawer>
<gl-modal
v-if="protectionRuleMutationItem"
v-model="showModal"
:modal-id="$options.modal.id"
size="sm"
:title="$options.i18n.protectionRuleDeletionConfirmModal.title"
@ -323,7 +369,7 @@ export default {
<p>
<gl-sprintf :message="$options.i18n.protectionRuleDeletionConfirmModal.description">
<template #tagNamePattern>
<strong>{{ protectionRuleMutationItem.tagNamePattern }}</strong>
<strong>{{ mutationItemTagNamePattern }}</strong>
</template>
</gl-sprintf>
</p>

View File

@ -0,0 +1,11 @@
mutation updateContainerProtectionTagRule($input: UpdateContainerProtectionTagRuleInput!) {
updateContainerProtectionTagRule(input: $input) {
containerProtectionTagRule {
id
tagNamePattern
minimumAccessLevelForPush
minimumAccessLevelForDelete
}
errors
}
}

View File

@ -13,6 +13,7 @@ import initSourceCodeDropdowns from '~/vue_shared/components/download_dropdown/i
import EmptyProject from '~/pages/projects/show/empty_project';
import initHeaderApp from '~/repository/init_header_app';
import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import CompactCodeDropdown from '~/repository/components/code_dropdown/compact_code_dropdown.vue';
import { initHomePanel } from '../home_panel';
// Project show page loads different overview content based on user preferences
@ -71,10 +72,15 @@ const initCodeDropdown = () => {
const { sshUrl, httpUrl, kerberosUrl } = codeDropdownEl.dataset;
const CodeDropdownComponent =
gon.features.directoryCodeDropdownUpdates && gon.features.blobRepositoryVueHeaderApp
? CompactCodeDropdown
: CodeDropdown;
return new Vue({
el: codeDropdownEl,
render(createElement) {
return createElement(CodeDropdown, {
return createElement(CodeDropdownComponent, {
props: {
sshUrl,
httpUrl,

View File

@ -68,7 +68,7 @@ export default {
<local-storage-sync v-model="isCollapsed" :storage-key="`wiki:${page.path}:collapsed`" />
<span
ref="entry"
class="wiki-list gl-relative gl-flex gl-cursor-pointer gl-items-center gl-rounded-base gl-px-3"
class="wiki-list gl-relative gl-mx-2 gl-mb-px gl-flex gl-min-h-8 gl-cursor-pointer gl-items-center gl-rounded-base gl-px-3"
data-testid="wiki-list"
:class="{ active: page.path === currentPath }"
@click="toggleCollapsed"

View File

@ -0,0 +1,91 @@
<script>
import { GlDisclosureDropdown, GlDisclosureDropdownGroup } from '@gitlab/ui';
import { getHTTPProtocol } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
import CodeDropdownItem from '~/vue_shared/components/code_dropdown/code_dropdown_item.vue';
export default {
components: {
GlDisclosureDropdown,
GlDisclosureDropdownGroup,
CodeDropdownItem,
},
props: {
sshUrl: {
type: String,
required: false,
default: '',
},
httpUrl: {
type: String,
required: false,
default: '',
},
kerberosUrl: {
type: String,
required: false,
default: null,
},
},
computed: {
httpLabel() {
const protocol = getHTTPProtocol(this.httpUrl)?.toUpperCase();
return sprintf(__('Clone with %{protocol}'), { protocol });
},
},
};
</script>
<template>
<gl-disclosure-dropdown
:toggle-text="__('Code')"
variant="confirm"
placement="bottom-end"
class="code-dropdown"
fluid-width
:auto-close="false"
data-testid="code-dropdown"
>
<gl-disclosure-dropdown-group v-if="sshUrl">
<code-dropdown-item
:label="__('Clone with SSH')"
label-class="!gl-text-sm !gl-pt-2"
:link="sshUrl"
name="ssh_project_clone"
input-id="copy-ssh-url-input"
test-id="copy-ssh-url-button"
/>
</gl-disclosure-dropdown-group>
<gl-disclosure-dropdown-group v-if="httpUrl">
<code-dropdown-item
:label="httpLabel"
label-class="!gl-text-sm !gl-pt-2"
:link="httpUrl"
name="http_project_clone"
input-id="copy-http-url-input"
test-id="copy-http-url-button"
/>
</gl-disclosure-dropdown-group>
<gl-disclosure-dropdown-group v-if="kerberosUrl">
<code-dropdown-item
:label="__('Clone with KRB5')"
label-class="!gl-text-sm !gl-pt-2"
:link="kerberosUrl"
name="kerberos_project_clone"
input-id="copy-http-url-input"
test-id="copy-http-url-button"
/>
</gl-disclosure-dropdown-group>
</gl-disclosure-dropdown>
</template>
<style>
/* Temporary override until we have
* widths available in GlDisclosureDropdown
* https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2501
*/
.code-dropdown .gl-new-dropdown-panel {
width: 100%;
max-width: 348px;
}
</style>

View File

@ -9,11 +9,13 @@ import { InternalEvents } from '~/tracking';
import { FIND_FILE_BUTTON_CLICK, REF_SELECTOR_CLICK } from '~/tracking/constants';
import { visitUrl, joinPaths, webIDEUrl } from '~/lib/utils/url_utility';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { generateRefDestinationPath } from '~/repository/utils/ref_switcher_utils';
import RefSelector from '~/ref/components/ref_selector.vue';
import Breadcrumbs from '~/repository/components/header_area/breadcrumbs.vue';
import BlobControls from '~/repository/components/header_area/blob_controls.vue';
import CodeDropdown from '~/vue_shared/components/code_dropdown/code_dropdown.vue';
import CompactCodeDropdown from '~/repository/components/code_dropdown/compact_code_dropdown.vue';
import SourceCodeDownloadDropdown from '~/vue_shared/components/download_dropdown/download_dropdown.vue';
import CloneCodeDropdown from '~/vue_shared/components/code_dropdown/clone_code_dropdown.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
@ -31,6 +33,7 @@ export default {
Breadcrumbs,
BlobControls,
CodeDropdown,
CompactCodeDropdown,
SourceCodeDownloadDropdown,
CloneCodeDropdown,
WebIdeLink: () => import('ee_else_ce/vue_shared/components/web_ide_link.vue'),
@ -40,6 +43,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagsMixin()],
inject: [
'canCollaborate',
'canEditTree',
@ -160,6 +164,9 @@ export default {
findFileShortcutKey() {
return keysFor(START_SEARCH_PROJECT_FILE)[0];
},
showCompactCodeDropdown() {
return this.glFeatures.directoryCodeDropdownUpdates;
},
},
methods: {
onInput(selectedRef) {
@ -273,27 +280,35 @@ export default {
/>
<!-- code + mobile panel -->
<div v-if="!isReadmeView" class="project-code-holder gl-w-full sm:gl-w-auto">
<code-dropdown
class="git-clone-holder js-git-clone-holder gl-hidden sm:gl-inline-block"
<compact-code-dropdown
v-if="showCompactCodeDropdown"
:ssh-url="sshUrl"
:http-url="httpUrl"
:kerberos-url="kerberosUrl"
:xcode-url="xcodeUrl"
:current-path="currentPath"
:directory-download-links="downloadLinks"
/>
<div class="gl-flex gl-items-stretch gl-gap-3 sm:gl-hidden">
<source-code-download-dropdown
:download-links="downloadLinks"
:download-artifacts="downloadArtifacts"
/>
<clone-code-dropdown
class="mobile-git-clone js-git-clone-holder !gl-w-full"
<template v-else>
<code-dropdown
class="git-clone-holder js-git-clone-holder gl-hidden sm:gl-inline-block"
:ssh-url="sshUrl"
:http-url="httpUrl"
:kerberos-url="kerberosUrl"
:xcode-url="xcodeUrl"
:current-path="currentPath"
:directory-download-links="downloadLinks"
/>
</div>
<div class="gl-flex gl-items-stretch gl-gap-3 sm:gl-hidden">
<source-code-download-dropdown
:download-links="downloadLinks"
:download-artifacts="downloadArtifacts"
/>
<clone-code-dropdown
class="mobile-git-clone js-git-clone-holder !gl-w-full"
:ssh-url="sshUrl"
:http-url="httpUrl"
:kerberos-url="kerberosUrl"
/>
</div>
</template>
</div>
</div>

View File

@ -9,6 +9,7 @@ import createStore from '~/code_navigation/store';
import RefSelector from '~/ref/components/ref_selector.vue';
import HighlightWorker from '~/vue_shared/components/source_viewer/workers/highlight_worker?worker';
import CodeDropdown from '~/vue_shared/components/code_dropdown/code_dropdown.vue';
import CompactCodeDropdown from '~/repository/components/code_dropdown/compact_code_dropdown.vue';
import App from './components/app.vue';
import Breadcrumbs from './components/header_area/breadcrumbs.vue';
import ForkInfo from './components/fork_info.vue';
@ -187,11 +188,16 @@ export default function setupVueRepositoryList() {
const { sshUrl, httpUrl, kerberosUrl, xcodeUrl, directoryDownloadLinks } =
codeDropdownEl.dataset;
const CodeDropdownComponent =
gon.features.directoryCodeDropdownUpdates && gon.features.blobRepositoryVueHeaderApp
? CompactCodeDropdown
: CodeDropdown;
return new Vue({
el: codeDropdownEl,
router,
render(createElement) {
return createElement(CodeDropdown, {
return createElement(CodeDropdownComponent, {
props: {
sshUrl,
httpUrl,

View File

@ -171,7 +171,7 @@ export default {
<template>
<div class="gl-flex gl-items-start gl-px-2" data-testid="todo-item-container">
<div class="gl-mr-3 gl-hidden sm:gl-inline-block">
<gl-avatar-link :href="author.webUrl">
<gl-avatar-link :href="author.webUrl" aria-hidden="true" tabindex="-1">
<gl-avatar :size="24" :src="author.avatarUrl" role="none" />
</gl-avatar-link>
</div>

View File

@ -322,14 +322,18 @@ export default {
<div>
<div class="gl-flex gl-flex-col">
<gl-loading-icon v-if="isLoading && showSpinnerWhileLoading" size="lg" class="gl-mt-5" />
<ul
<div
v-else
data-testid="todo-item-list-container"
class="gl-m-0 gl-border-collapse gl-list-none gl-p-0"
@mouseenter="startedInteracting"
@mouseleave="stoppedInteracting"
>
<transition-group name="todos">
<transition-group
name="todos"
tag="ol"
data-testid="todo-item-list"
class="gl-m-0 gl-border-collapse gl-list-none gl-p-0"
>
<todo-item
v-for="todo in todos"
:key="todo.id"
@ -338,7 +342,7 @@ export default {
@change="handleItemChanged"
/>
</transition-group>
</ul>
</div>
<todos-empty-state v-if="showEmptyState" :is-filtered="isFiltered" />

View File

@ -152,7 +152,7 @@ export default {
<template v-if="activePanel">
<div data-testid="active-panel-template" class="gl-flex gl-items-center gl-py-5">
<div class="col-auto">
<img :alt="''" :src="activePanel.imageSrc" />
<img aria-hidden="true" :src="activePanel.imageSrc" :alt="activePanel.title" />
</div>
<div class="col">
<h1 class="gl-heading-2-fixed gl-my-3">{{ activePanel.title }}</h1>

View File

@ -31,7 +31,6 @@ import {
WIDGET_TYPE_DESIGNS,
LINKED_ITEMS_ANCHOR,
WORK_ITEM_REFERENCE_CHAR,
WORK_ITEM_TYPE_VALUE_TASK,
WORK_ITEM_TYPE_VALUE_EPIC,
WIDGET_TYPE_WEIGHT,
WIDGET_TYPE_DEVELOPMENT,
@ -350,15 +349,6 @@ export default {
return Boolean(parentWorkItem);
},
shouldShowAncestors() {
// TODO: This is a temporary check till the issue work item migration is completed
// Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/468114
if (
this.workItemType === WORK_ITEM_TYPE_VALUE_TASK &&
!this.glFeatures.namespaceLevelWorkItems
) {
return false;
}
// Checks whether current work item has parent
// or it is in hierarchy but there is no permission to view the parent
return this.hasParent || this.workItemHierarchy?.hasParent;

View File

@ -27,10 +27,6 @@
}
li > .wiki-list {
min-height: 30px;
@apply gl-mx-2;
margin-bottom: 1px;
&:hover,
&.active {
@apply gl-bg-neutral-50 dark:gl-bg-neutral-900;

View File

@ -11,6 +11,13 @@ module EnforcesAdminAuthentication
included do
before_action :authenticate_admin!
def self.authorize!(ability, only:)
actions = Array(only)
skip_before_action :authenticate_admin!, only: actions
before_action -> { authorize_ability!(ability) }, only: actions
end
end
def authenticate_admin!
@ -27,4 +34,12 @@ module EnforcesAdminAuthentication
def storable_location?
request.path != new_admin_session_path
end
private
def authorize_ability!(ability)
return authenticate_admin! if current_user.admin?
render_404 unless current_user.can?(ability)
end
end

View File

@ -65,7 +65,6 @@ class Projects::IssuesController < Projects::ApplicationController
before_action only: :show do
push_frontend_feature_flag(:epic_widget_edit_confirmation, project)
push_frontend_feature_flag(:namespace_level_work_items, project&.group)
push_frontend_feature_flag(:work_items_view_preference, current_user)
end

View File

@ -22,6 +22,7 @@ class Projects::TreeController < Projects::ApplicationController
push_frontend_feature_flag(:blob_repository_vue_header_app, @project)
push_frontend_feature_flag(:blob_overflow_menu, current_user)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
push_frontend_feature_flag(:directory_code_dropdown_updates, current_user)
end
feature_category :source_code_management

View File

@ -44,6 +44,7 @@ class ProjectsController < Projects::ApplicationController
push_frontend_feature_flag(:page_specific_styles, current_user)
push_frontend_feature_flag(:blob_repository_vue_header_app, @project)
push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks)
push_frontend_feature_flag(:directory_code_dropdown_updates, current_user)
if @project.present? && @project.licensed_feature_available?(:security_orchestration_policies)
push_licensed_feature(:security_orchestration_policies)
@ -52,7 +53,6 @@ class ProjectsController < Projects::ApplicationController
push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?)
push_force_frontend_feature_flag(:work_items_beta, @project&.work_items_beta_feature_flag_enabled?)
push_force_frontend_feature_flag(:work_items_alpha, @project&.work_items_alpha_feature_flag_enabled?)
push_frontend_feature_flag(:namespace_level_work_items, @project&.group)
push_force_frontend_feature_flag(:custom_fields_feature, @project&.group)
end

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
module Repositories
class ProtectedBranchCreatedEvent < ::Gitlab::EventStore::Event
PARENT_TYPES = {
group: 'group',
project: 'project'
}.freeze
def schema
{
'type' => 'object',
'properties' => {
'protected_branch_id' => { 'type' => 'integer' },
'parent_id' => { 'type' => 'integer' },
'parent_type' => { 'type' => 'string', 'enum' => PARENT_TYPES.values }
},
'required' => %w[protected_branch_id parent_id parent_type]
}
end
end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
module Repositories
class ProtectedBranchDestroyedEvent < ::Gitlab::EventStore::Event
PARENT_TYPES = {
group: 'group',
project: 'project'
}.freeze
def schema
{
'type' => 'object',
'properties' => {
'parent_id' => { 'type' => 'integer' },
'parent_type' => { 'type' => 'string', 'enum' => PARENT_TYPES.values }
},
'required' => %w[parent_id parent_type]
}
end
end
end

View File

@ -4,6 +4,16 @@ module Namespaces
module GroupsFilter
private
def by_ids(items)
ids = params[:ids]
items = items.id_in(ids) if ids
items
end
def top_level_only(groups)
params[:top_level_only].present? ? groups.by_parent(nil) : groups
end
def by_search(groups)
return groups unless params[:search].present?

View File

@ -113,12 +113,6 @@ class GroupsFinder < UnionFinder
by_search(groups)
end
def by_ids(items)
ids = params[:ids]
items = items.id_in(ids) if ids
items
end
def by_organization(groups)
organization = params[:organization]
return groups unless organization
@ -136,10 +130,6 @@ class GroupsFinder < UnionFinder
end
end
def top_level_only(groups)
params[:top_level_only].present? ? groups.by_parent(nil) : groups
end
def by_parent_descendants(groups, parent)
if include_parent_shared_groups?
groups.descendants_with_shared_with_groups(parent)

View File

@ -14,6 +14,9 @@ class Board < ApplicationRecord
validates :name, presence: true
validates :project, presence: true, if: :project_needed?
validates :group, presence: true, unless: :project
validates :group, absence: {
message: ->(_object, _data) { _("can't be specified if a project was already provided") }
}, if: :project
scope :with_associations, -> { preload(:destroyable_lists) }

View File

@ -462,7 +462,7 @@ module Ci
Gitlab::Ci::Variables::Collection.new
.append(key: 'CI_RUNNER_ID', value: id.to_s)
.append(key: 'CI_RUNNER_DESCRIPTION', value: description)
.append(key: 'CI_RUNNER_TAGS', value: tag_list.to_s)
.append(key: 'CI_RUNNER_TAGS', value: tag_list.to_a.to_s)
end
def tick_runner_queue

View File

@ -70,6 +70,9 @@
"read_admin_monitoring": {
"type": "boolean"
},
"read_admin_subscription": {
"type": "boolean"
},
"read_code": {
"type": "boolean"
},

View File

@ -0,0 +1,9 @@
---
name: directory_code_dropdown_updates
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/450667
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/178890
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/514750
milestone: '17.9'
group: group::source code
type: wip
default_enabled: false

View File

@ -889,6 +889,8 @@
- 1
- - security_sync_policy
- 1
- - security_sync_policy_event
- 1
- - security_sync_policy_violation_comment
- 1
- - security_sync_project_policies

View File

@ -6,7 +6,7 @@ class AddAiSettingsTable < Gitlab::Database::Migration[2.2]
disable_ddl_transaction!
def up
create_table :ai_settings do |t| # rubocop:disable Migration/EnsureFactoryForTable -- FactoryBot does not support Singleton classes https://github.com/thoughtbot/factory_bot/issues/642
create_table :ai_settings do |t|
t.text :ai_gateway_url, limit: 2048 # Most browsers support URLs up to 2048 characters
t.boolean :singleton, null: false, default: true, comment: 'Always true, used for singleton enforcement'
end

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
class AddExternalUrlToComplianceRequirementsControls < Gitlab::Database::Migration[2.2]
milestone '17.9'
disable_ddl_transaction!
def up
with_lock_retries do
add_column :compliance_requirements_controls, :external_url, :text, if_not_exists: true
end
add_text_limit :compliance_requirements_controls, :external_url, 1024
end
def down
with_lock_retries do
remove_column :compliance_requirements_controls, :external_url, if_exists: true
end
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class AddTempBoardProjectGroupNullIndex < Gitlab::Database::Migration[2.2]
INDEX_NAME = 'tmp_idx_boards_on_project_group_both_missing'
disable_ddl_transaction!
milestone '17.9'
def up
add_concurrent_index :boards, :id, name: INDEX_NAME, where: 'group_id IS NULL AND project_id IS NULL'
end
def down
remove_concurrent_index :boards, :id, name: INDEX_NAME
end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class DeleteBoardsWithoutProjectAndGroup < Gitlab::Database::Migration[2.2]
BATCH_SIZE = 100
disable_ddl_transaction!
restrict_gitlab_migration gitlab_schema: :gitlab_main
milestone '17.9'
def up
batch_scope = ->(model) { model.where('group_id IS NULL AND project_id IS NULL') }
each_batch(:boards, scope: batch_scope, of: BATCH_SIZE) do |batch|
batch.delete_all
end
end
def down
# no-op
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class AddTempBoardProjectGroupIndex < Gitlab::Database::Migration[2.2]
INDEX_NAME = 'tmp_idx_boards_on_project_group_both_present'
disable_ddl_transaction!
milestone '17.9'
def up
add_concurrent_index :boards, :id, name: INDEX_NAME, where: 'group_id IS NOT NULL AND project_id IS NOT NULL'
end
def down
remove_concurrent_index :boards, :id, name: INDEX_NAME
end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class FixBoardsWithProjectAndGroup < Gitlab::Database::Migration[2.2]
BATCH_SIZE = 100
disable_ddl_transaction!
restrict_gitlab_migration gitlab_schema: :gitlab_main
milestone '17.9'
def up
batch_scope = ->(model) { model.where('project_id IS NOT NULL AND group_id IS NOT NULL') }
each_batch(:boards, scope: batch_scope, of: BATCH_SIZE) do |batch|
batch.update_all(group_id: nil)
end
end
def down
# no-op
end
end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
class AddBoardsUniqueParentConstraint < Gitlab::Database::Migration[2.2]
disable_ddl_transaction!
milestone '17.9'
def up
add_multi_column_not_null_constraint(:boards, :group_id, :project_id)
end
def down
remove_multi_column_not_null_constraint(:boards, :group_id, :project_id)
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class DropBoardsTempParentIndexBothMissing < Gitlab::Database::Migration[2.2]
INDEX_NAME = 'tmp_idx_boards_on_project_group_both_missing'
disable_ddl_transaction!
milestone '17.9'
def up
remove_concurrent_index :boards, :id, name: INDEX_NAME
end
def down
add_concurrent_index :boards, :id, name: INDEX_NAME, where: 'group_id IS NULL AND project_id IS NULL'
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class DropBoardsTempParentIndex < Gitlab::Database::Migration[2.2]
INDEX_NAME = 'tmp_idx_boards_on_project_group_both_present'
disable_ddl_transaction!
milestone '17.9'
def up
remove_concurrent_index :boards, :id, name: INDEX_NAME
end
def down
add_concurrent_index :boards, :id, name: INDEX_NAME, where: 'group_id IS NOT NULL AND project_id IS NOT NULL'
end
end

View File

@ -0,0 +1 @@
c31bd7c693e5ee898ef1ef7c82776b76b4ecec0f213385506175ee43d10ed0f2

View File

@ -0,0 +1 @@
10581a303c2bfb3368e373291feab98bba7407e42272b55696c8f08b84a8fef1

View File

@ -0,0 +1 @@
7a24f0d4e789df4cb437a6ceca86d183cce64c81c166b67fd6be24b5215dd15a

View File

@ -0,0 +1 @@
06e89359a432c09d0b56bb06b0f3ad88d67f37c4a940cf76c08a4e89393bc353

View File

@ -0,0 +1 @@
93cd80fb8913498e97dc4df5e2ddac594881cb70c8d755bfa6d276ef79f8a3b4

View File

@ -0,0 +1 @@
7c11d2dc13a5e56e0f3e6e437967fb7871c7adcd5293832076a18fbbc9a8b191

View File

@ -0,0 +1 @@
0d190ed83358367421a8eabd32736fa3694340179e5b2565a1c73bd743086a6f

View File

@ -0,0 +1 @@
936a8dae4bfec0189636d1cc395beb5152e42ca81963fb45e1c4bc7aa45e9787

View File

@ -9124,7 +9124,8 @@ CREATE TABLE boards (
hide_backlog_list boolean DEFAULT false NOT NULL,
hide_closed_list boolean DEFAULT false NOT NULL,
iteration_id bigint,
iteration_cadence_id bigint
iteration_cadence_id bigint,
CONSTRAINT check_a60857cc50 CHECK ((num_nonnulls(group_id, project_id) = 1))
);
CREATE TABLE boards_epic_board_labels (
@ -11436,7 +11437,9 @@ CREATE TABLE compliance_requirements_controls (
expression text,
encrypted_secret_token bytea,
encrypted_secret_token_iv bytea,
CONSTRAINT check_110c87ed8d CHECK ((char_length(expression) <= 255))
external_url text,
CONSTRAINT check_110c87ed8d CHECK ((char_length(expression) <= 255)),
CONSTRAINT check_5020dd6745 CHECK ((char_length(external_url) <= 1024))
);
CREATE SEQUENCE compliance_requirements_controls_id_seq

View File

@ -32538,7 +32538,6 @@ Project-level settings for product analytics provider.
| <a id="projectsavedreplies"></a>`savedReplies` | [`ProjectSavedReplyConnection`](#projectsavedreplyconnection) | Saved replies available to the project. (see [Connections](#connections)) |
| <a id="projectsecuritydashboardpath"></a>`securityDashboardPath` | [`String`](#string) | Path to project's security dashboard. |
| <a id="projectsecuritypolicyproject"></a>`securityPolicyProject` | [`Project`](#project) | Security policy project assigned to the project, absent if assigned to a parent group. |
| <a id="projectsecuritypolicyprojectlinkedgroups"></a>`securityPolicyProjectLinkedGroups` | [`GroupConnection`](#groupconnection) | Groups linked to the project, when used as Security Policy Project. (see [Connections](#connections)) |
| <a id="projectsecuritypolicyprojectlinkednamespaces"></a>`securityPolicyProjectLinkedNamespaces` **{warning-solid}** | [`NamespaceConnection`](#namespaceconnection) | **Deprecated** in GitLab 17.4. This was renamed. Use: `security_policy_project_linked_groups`. |
| <a id="projectsecuritypolicyprojectlinkedprojects"></a>`securityPolicyProjectLinkedProjects` | [`ProjectConnection`](#projectconnection) | Projects linked to the project, when used as Security Policy Project. (see [Connections](#connections)) |
| <a id="projectsecurityscanners"></a>`securityScanners` | [`SecurityScanners`](#securityscanners) | Information about security analyzers used in the project. |
@ -34214,6 +34213,24 @@ four standard [pagination arguments](#pagination-arguments):
| <a id="projectsecurityexclusionsscanner"></a>`scanner` | [`ExclusionScannerEnum`](#exclusionscannerenum) | Filter entries by scanner. |
| <a id="projectsecurityexclusionstype"></a>`type` | [`ExclusionTypeEnum`](#exclusiontypeenum) | Filter entries by exclusion type. |
##### `Project.securityPolicyProjectLinkedGroups`
Groups linked to the project, when used as Security Policy Project.
Returns [`GroupConnection`](#groupconnection).
This field returns a [connection](#connections). It accepts the
four standard [pagination arguments](#pagination-arguments):
`before: String`, `after: String`, `first: Int`, and `last: Int`.
###### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="projectsecuritypolicyprojectlinkedgroupsids"></a>`ids` | [`[ID!]`](#id) | Filter groups by IDs. |
| <a id="projectsecuritypolicyprojectlinkedgroupssearch"></a>`search` | [`String`](#string) | Search query for groups. |
| <a id="projectsecuritypolicyprojectlinkedgroupstoplevelonly"></a>`topLevelOnly` | [`Boolean`](#boolean) | Only include top-level groups. |
##### `Project.securityPolicyProjectSuggestions`
Security policy project suggestions.
@ -41034,6 +41051,7 @@ Member role admin permission.
| <a id="memberroleadminpermissionread_admin_cicd"></a>`READ_ADMIN_CICD` | Read CI/CD details including runners and jobs. |
| <a id="memberroleadminpermissionread_admin_dashboard"></a>`READ_ADMIN_DASHBOARD` | Read-only access to admin dashboard. |
| <a id="memberroleadminpermissionread_admin_monitoring"></a>`READ_ADMIN_MONITORING` | Allows read access to system monitoring including system info, background migrations, health checks, audit logs, and gitaly in the Admin Area. |
| <a id="memberroleadminpermissionread_admin_subscription"></a>`READ_ADMIN_SUBSCRIPTION` | Read subscription details in the Admin area. |
### `MemberRolePermission`
@ -41063,6 +41081,7 @@ Member role permission.
| <a id="memberrolepermissionread_admin_cicd"></a>`READ_ADMIN_CICD` | Read CI/CD details including runners and jobs. |
| <a id="memberrolepermissionread_admin_dashboard"></a>`READ_ADMIN_DASHBOARD` | Read-only access to admin dashboard. |
| <a id="memberrolepermissionread_admin_monitoring"></a>`READ_ADMIN_MONITORING` | Allows read access to system monitoring including system info, background migrations, health checks, audit logs, and gitaly in the Admin Area. |
| <a id="memberrolepermissionread_admin_subscription"></a>`READ_ADMIN_SUBSCRIPTION` | Read subscription details in the Admin area. |
| <a id="memberrolepermissionread_code"></a>`READ_CODE` | Allows read-only access to the source code in the user interface. Does not allow users to edit or download repository archives, clone or pull repositories, view source code in an IDE, or view merge requests for private projects. You can download individual files because read-only access inherently grants the ability to make a local copy of the file. |
| <a id="memberrolepermissionread_compliance_dashboard"></a>`READ_COMPLIANCE_DASHBOARD` | Read compliance capabilities including adherence, violations, and frameworks for groups and projects. |
| <a id="memberrolepermissionread_crm_contact"></a>`READ_CRM_CONTACT` | Read CRM contact. |

View File

@ -79,7 +79,7 @@ To do so, either:
When you upload an image, you can add text to the image and link it to the original graph.
![Text link modal](img/incident_metrics_tab_text_link_modal_v14_9.png)
![An incident metrics tab with an option to add a text link](img/incident_metrics_tab_text_link_modal_v14_9.png)
If you add a link, it is shown above the uploaded image.

View File

@ -384,7 +384,7 @@ ci_access:
- dev
```
For more details, see [Access to Kubernetes from CI](https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent/-/blob/b601fa21cac24f0cdedc8b8eb59ebcba0b70f459/doc/kubernetes_ci_access.md#apiv4joballowed_agents-api).
For more details, see [Access to Kubernetes from CI/CD](https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent/-/blob/master/doc/kubernetes_ci_access.md#apiv4joballowed_agents-api).
## Related topics

View File

@ -7,7 +7,13 @@ module Sidebars
def render?
return false unless context.current_user
context.current_user.can_admin_all_resources?
render_with_abilities.any? { |ability| context.current_user.can?(ability) }
end
private
def render_with_abilities
%i[admin_all_resources]
end
end
end

View File

@ -15605,6 +15605,9 @@ msgstr ""
msgid "ContainerRegistry|Edit cleanup rules"
msgstr ""
msgid "ContainerRegistry|Edit protection rule"
msgstr ""
msgid "ContainerRegistry|Enable cleanup policy"
msgstr ""
@ -15683,6 +15686,12 @@ msgstr ""
msgid "ContainerRegistry|Minimum role allowed to push"
msgstr ""
msgid "ContainerRegistry|Minimum role to delete: "
msgstr ""
msgid "ContainerRegistry|Minimum role to push: "
msgstr ""
msgid "ContainerRegistry|Missing or insufficient permission, delete button disabled"
msgstr ""
@ -15898,6 +15907,9 @@ msgstr ""
msgid "ContainerRegistry|This project's cleanup policy for tags is not enabled."
msgstr ""
msgid "ContainerRegistry|This tag is protected"
msgstr ""
msgid "ContainerRegistry|To widen your search, change or remove the filters above."
msgstr ""
@ -50971,7 +50983,7 @@ msgstr ""
msgid "SecurityApprovals|Learn more about Coverage-Check"
msgstr ""
msgid "SecurityApprovals|Requires approval for decreases in test coverage. %{linkStart}Learn more%{linkEnd}."
msgid "SecurityApprovals|Requires approval for decreases in test coverage."
msgstr ""
msgid "SecurityConfiguration|%{featureName} merge request creation mutation failed"
@ -67155,6 +67167,9 @@ msgstr ""
msgid "can't be solely blank"
msgstr ""
msgid "can't be specified if a project was already provided"
msgstr ""
msgid "can't be the same as the source project"
msgstr ""

View File

@ -214,8 +214,6 @@ spec/frontend/packages_and_registries/package_registry/pages/details_spec.js
spec/frontend/packages_and_registries/settings/group/components/forwarding_settings_spec.js
spec/frontend/packages_and_registries/settings/project/settings/components/cleanup_image_tags_spec.js
spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js
spec/frontend/packages_and_registries/settings/project/settings/components/container_protection_repository_rules_spec.js
spec/frontend/packages_and_registries/settings/project/settings/components/packages_protection_rules_spec.js
spec/frontend/pages/projects/forks/new/components/fork_form_spec.js
spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js
spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js

View File

@ -3,8 +3,6 @@
require 'spec_helper'
RSpec.describe EnforcesAdminAuthentication do
include AdminModeHelper
let(:user) { create(:user) }
before do
@ -19,6 +17,49 @@ RSpec.describe EnforcesAdminAuthentication do
end
end
describe '.authorize!' do
controller(ApplicationController) do
include EnforcesAdminAuthentication
authorize! :ability, only: :index
def index
head :ok
end
end
context 'when the user is an admin', :enable_admin_mode do
let(:user) { create(:admin) }
it 'renders ok' do
get :index
expect(response).to have_gitlab_http_status(:ok)
end
end
context 'when the user is a regular user' do
it 'renders a 404' do
get :index
expect(response).to have_gitlab_http_status(:not_found)
end
context 'when an ability grants access' do
before do
allow(Ability).to receive(:allowed?).and_call_original
allow(Ability).to receive(:allowed?).with(user, :ability, :global).and_return(true)
end
it 'renders ok' do
get :index
expect(response).to have_gitlab_http_status(:ok)
end
end
end
end
context 'application setting :admin_mode is enabled' do
describe 'authenticate_admin!' do
context 'as an admin' do
@ -31,11 +72,7 @@ RSpec.describe EnforcesAdminAuthentication do
expect(assigns(:current_user_mode)&.admin_mode?).to be(false)
end
context 'when admin mode is active' do
before do
enable_admin_mode!(user)
end
context 'when admin mode is active', :enable_admin_mode do
it 'renders ok' do
get :index

View File

@ -40,7 +40,7 @@ RSpec.describe 'Dashboard Todos', :js, feature_category: :team_planning do
wait_for_requests # ensures page is fully loaded
end
xit 'passes axe automated accessibility testing' do # rubocop:disable RSpec/PendingWithoutReason -- TODO: Fix violations in Vue components
it 'passes axe automated accessibility testing' do
expect(page).to be_axe_clean.within('#content-body')
end
end
@ -53,7 +53,7 @@ RSpec.describe 'Dashboard Todos', :js, feature_category: :team_planning do
wait_for_requests # ensures page is fully loaded
end
xit 'passes axe automated accessibility testing' do # rubocop:disable RSpec/PendingWithoutReason -- TODO: Fix violations in Vue components
it 'passes axe automated accessibility testing' do
expect(page).to be_axe_clean.within('#content-body')
end
end

View File

@ -44,7 +44,7 @@ RSpec.describe 'Dashboard > User sorts todos', :js, feature_category: :notificat
it 'updates sort order and direction' do
# Default order is created_at DESC
results_list = page.find('ul[data-testid=todo-item-list-container]')
results_list = page.find('ol[data-testid="todo-item-list"]')
expect(results_list.all('[data-testid=todo-title]')[0]).to have_content('merge_request_1')
expect(results_list.all('[data-testid=todo-title]')[1]).to have_content('issue_1')
expect(results_list.all('[data-testid=todo-title]')[2]).to have_content('issue_3')
@ -53,7 +53,7 @@ RSpec.describe 'Dashboard > User sorts todos', :js, feature_category: :notificat
# Switch order to created_at ASC
click_on_sort_direction
results_list = page.find('ul[data-testid=todo-item-list-container]')
results_list = page.find('ol[data-testid="todo-item-list"]')
expect(results_list.all('[data-testid=todo-title]')[0]).to have_content('issue_4')
expect(results_list.all('[data-testid=todo-title]')[1]).to have_content('issue_2')
expect(results_list.all('[data-testid=todo-title]')[2]).to have_content('issue_3')
@ -62,7 +62,7 @@ RSpec.describe 'Dashboard > User sorts todos', :js, feature_category: :notificat
# Change direction to 'Label priority' ASC
click_on_sort_order 'Label priority'
results_list = page.find('ul[data-testid=todo-item-list-container]')
results_list = page.find('ol[data-testid="todo-item-list"]')
expect(results_list.all('[data-testid=todo-title]')[0]).to have_content('issue_3')
expect(results_list.all('[data-testid=todo-title]')[1]).to have_content('merge_request_1')
expect(results_list.all('[data-testid=todo-title]')[2]).to have_content('issue_1')
@ -72,7 +72,7 @@ RSpec.describe 'Dashboard > User sorts todos', :js, feature_category: :notificat
# Change direction to updated_at DESC
click_on_sort_order 'Updated'
click_on_sort_direction
results_list = page.find('ul[data-testid=todo-item-list-container]')
results_list = page.find('ol[data-testid="todo-item-list"]')
expect(results_list.all('[data-testid=todo-title]')[0]).to have_content('issue_3')
expect(results_list.all('[data-testid=todo-title]')[1]).to have_content('merge_request_1')
expect(results_list.all('[data-testid=todo-title]')[2]).to have_content('issue_1')
@ -108,7 +108,7 @@ RSpec.describe 'Dashboard > User sorts todos', :js, feature_category: :notificat
end
it "doesn't mix issues and merge requests label priorities" do
results_list = page.find('ul[data-testid=todo-item-list-container]')
results_list = page.find('ol[data-testid="todo-item-list"]')
expect(results_list.all('[data-testid=todo-title]')[0]).to have_content('issue_1')
expect(results_list.all('[data-testid=todo-title]')[1]).to have_content('issue_2')
expect(results_list.all('[data-testid=todo-title]')[2]).to have_content('merge_request_1')

View File

@ -43,7 +43,7 @@ RSpec.describe 'Dashboard Todos', :js, feature_category: :notifications do
visit dashboard_todos_path
expect(page).to have_selector('ul[data-testid="todo-item-list-container"] li', count: 1)
expect(page).to have_selector('ol[data-testid="todo-item-list"] > li', count: 1)
end
end
@ -190,7 +190,7 @@ RSpec.describe 'Dashboard Todos', :js, feature_category: :notifications do
end
it 'shows page with two Todos' do
expect(page).to have_selector('ul[data-testid="todo-item-list-container"] li', count: 2)
expect(page).to have_selector('ol[data-testid="todo-item-list"] > li', count: 2)
end
end
@ -231,7 +231,7 @@ RSpec.describe 'Dashboard Todos', :js, feature_category: :notifications do
end
it 'has todo present' do
expect(page).to have_selector('ul[data-testid="todo-item-list-container"] li', count: 1)
expect(page).to have_selector('ol[data-testid="todo-item-list"] > li', count: 1)
end
end
@ -241,7 +241,7 @@ RSpec.describe 'Dashboard Todos', :js, feature_category: :notifications do
sign_in(user)
visit dashboard_todos_path
expect(page).to have_selector('ul[data-testid="todo-item-list-container"] li', count: 1)
expect(page).to have_selector('ol[data-testid="todo-item-list"] > li', count: 1)
expect(page).to have_content "#{author.name} has requested access to #{target_type} #{target_name}"
end
end
@ -499,7 +499,7 @@ RSpec.describe 'Dashboard Todos', :js, feature_category: :notifications do
find('a.gl-toast-action', text: 'Undo').click
end
expect(page).to have_content 'Restored 3 to-dos'
expect(page).to have_selector('ul[data-testid=todo-item-list-container] li', count: 3)
expect(page).to have_selector('ol[data-testid="todo-item-list"] > li', count: 3)
end
end
@ -510,7 +510,7 @@ RSpec.describe 'Dashboard Todos', :js, feature_category: :notifications do
expect(page).to have_content 'Sorry, your filter produced no results'
click_on 'Clear'
expect(page).to have_selector('ul[data-testid=todo-item-list-container] li', count: 1)
expect(page).to have_selector('ol[data-testid="todo-item-list"] > li', count: 1)
expect(page).to have_content(other_assigned.author.name)
end
end

View File

@ -251,6 +251,27 @@ RSpec.describe 'Project > Settings > Packages and registries',
expect(settings_block).to have_content('Owner')
end
it 'updates a rule' do
visit_method
within_testid settings_block_id do
click_button 'Edit'
end
expect(page).to have_selector 'h2', text: 'Edit protection rule'
fill_in 'Protect container tags matching', with: 'v1.*'
select 'Maintainer', from: 'Minimum role allowed to push'
select 'Maintainer', from: 'Minimum role allowed to delete'
click_button 'Save changes'
settings_block = find_by_testid(settings_block_id)
expect(page).not_to have_selector 'h2', text: 'Edit protection rule'
expect(page).to have_content('Container protection rule updated.')
expect(settings_block).to have_content('v1.*')
expect(find_by_testid('minimum-access-level-push-value')).to have_content('Maintainer')
expect(find_by_testid('minimum-access-level-delete-value')).to have_content('Maintainer')
end
it 'deletes rule' do
visit_method

View File

@ -182,6 +182,7 @@ RSpec.describe 'Project', feature_category: :source_code_management do
let(:path) { project_path(project) }
before do
stub_feature_flags(directory_code_dropdown_updates: false)
sign_in(project.first_owner)
visit path
end

View File

@ -24,6 +24,8 @@ RSpec.describe 'Container registry (JavaScript fixtures)', feature_category: :co
"#{base_path}/mutations/create_container_protection_tag_rule.mutation.graphql"
delete_container_protection_tag_rule_mutation_path =
"#{base_path}/mutations/delete_container_protection_tag_rule.mutation.graphql"
update_container_protection_tag_rule_mutation_path =
"#{base_path}/mutations/update_container_protection_tag_rule.mutation.graphql"
context 'when user does not have access to the project' do
it "graphql/#{project_container_protection_tag_rules_query_path}.null_project.json" do
@ -199,6 +201,87 @@ RSpec.describe 'Container registry (JavaScript fixtures)', feature_category: :co
.to include('Tag name pattern has already been taken')
end
end
describe 'updating a rule' do
let_it_be(:container_protection_tag_rule) do
create(:container_registry_protection_tag_rule,
project: project,
minimum_access_level_for_push: Gitlab::Access::MAINTAINER,
minimum_access_level_for_delete: Gitlab::Access::OWNER
)
end
context 'when there are no errors' do
it "graphql/#{update_container_protection_tag_rule_mutation_path}.json" do
mutation = get_graphql_query_as_string(update_container_protection_tag_rule_mutation_path)
post_graphql(
mutation,
current_user: owner,
variables: {
input: {
id: "gid://gitlab/ContainerRegistry::Protection::TagRule/#{container_protection_tag_rule.id}",
tagNamePattern: 'v.*',
minimumAccessLevelForPush: 'ADMIN',
minimumAccessLevelForDelete: 'ADMIN'
}
}
)
expect_graphql_errors_to_be_empty
end
end
context 'when there are field errors' do
it "graphql/#{update_container_protection_tag_rule_mutation_path}.server_errors.json" do
mutation = get_graphql_query_as_string(update_container_protection_tag_rule_mutation_path)
post_graphql(
mutation,
current_user: owner,
variables: {
input: {
id: "gid://gitlab/ContainerRegistry::Protection::TagRule/#{container_protection_tag_rule.id}",
tagNamePattern: '',
minimumAccessLevelForPush: 'MAINTAINER',
minimumAccessLevelForDelete: 'OWNER'
}
}
)
expect_graphql_errors_to_include(
"tagNamePattern can't be blank"
)
end
end
context 'when there are errors' do
before do
create(:container_registry_protection_tag_rule, project: project,
tag_name_pattern: "v.*")
end
it "graphql/#{update_container_protection_tag_rule_mutation_path}.errors.json" do
mutation = get_graphql_query_as_string(update_container_protection_tag_rule_mutation_path)
post_graphql(
mutation,
current_user: owner,
variables: {
input: {
id: "gid://gitlab/ContainerRegistry::Protection::TagRule/#{container_protection_tag_rule.id}",
tagNamePattern: 'v.*',
minimumAccessLevelForPush: 'MAINTAINER',
minimumAccessLevelForDelete: 'OWNER'
}
}
)
expect(graphql_data_at('updateContainerProtectionTagRule', 'errors'))
.to include('Tag name pattern has already been taken')
end
end
end
end
end
end

View File

@ -4,7 +4,6 @@ import {
GlIcon,
GlDisclosureDropdown,
GlDisclosureDropdownItem,
GlBadge,
GlLink,
} from '@gitlab/ui';
import { nextTick } from 'vue';
@ -28,6 +27,10 @@ import { ListItem } from '../../stubs';
describe('tags list row', () => {
let wrapper;
const tag = tagsMock[0];
const protection = {
minimumAccessLevelForPush: 'MAINTAINER',
minimumAccessLevelForDelete: 'MAINTAINER',
};
const tagWithOCIMediaType = tagsMock[2];
const tagWithListMediaType = tagsMock[3];
@ -49,12 +52,14 @@ describe('tags list row', () => {
const findWarningIcon = () => wrapper.findComponent(GlIcon);
const findAdditionalActionsMenu = () => wrapper.findComponent(GlDisclosureDropdown);
const findDeleteButton = () => wrapper.findComponent(GlDisclosureDropdownItem);
const findSignedBadge = () => wrapper.findComponent(GlBadge);
const findSignedBadge = () => wrapper.findByTestId('signed-badge');
const findIndexBadge = () => wrapper.findByTestId('index-badge');
const findSignatureDetailsModal = () => wrapper.findComponent(SignatureDetailsModal);
const getTooltipFor = (component) => getBinding(component.element, 'gl-tooltip');
const findProtectedBadge = () => wrapper.findByTestId('protected-badge');
const findProtectedPopover = () => wrapper.findByTestId('protected-popover');
const mountComponent = (propsData = defaultProps) => {
const mountComponent = (propsData = defaultProps, protectedTagsFeatureFlagState = false) => {
wrapper = shallowMountExtended(TagsListRow, {
stubs: {
GlSprintf,
@ -65,6 +70,11 @@ describe('tags list row', () => {
},
propsData,
directives: { GlTooltip: createMockDirective('gl-tooltip') },
provide: {
glFeatures: {
containerRegistryProtectedTags: protectedTagsFeatureFlagState,
},
},
});
};
@ -168,6 +178,53 @@ describe('tags list row', () => {
});
});
describe('protected tag', () => {
it('hidden if tag.protection does not exists', () => {
mountComponent(defaultProps, true);
expect(findProtectedBadge().exists()).toBe(false);
});
it('displays if tag.protection exists', () => {
mountComponent(
{
...defaultProps,
tag: {
...tag,
protection: {
...protection,
},
},
},
true,
);
expect(findProtectedBadge().exists()).toBe(true);
});
it('has the correct text for the popover', () => {
mountComponent(
{
...defaultProps,
tag: {
...tag,
protection: {
...protection,
},
},
},
true,
);
const popoverText = findProtectedPopover().text();
expect(popoverText).toContain('This tag is protected');
expect(popoverText).toContain('Minimum role to push:');
expect(popoverText).toContain('Minimum role to delete:');
expect(popoverText).toContain('MAINTAINER');
});
});
describe('warning icon', () => {
it('is normally hidden', () => {
mountComponent();

View File

@ -200,6 +200,10 @@ export const tagsMock = [
userPermissions: {
destroyContainerRepositoryTag: true,
},
protection: {
minimumAccessLevelForPush: null,
minimumAccessLevelForDelete: null,
},
__typename: 'ContainerRepositoryTag',
},
{
@ -217,6 +221,10 @@ export const tagsMock = [
userPermissions: {
destroyContainerRepositoryTag: true,
},
protection: {
minimumAccessLevelForPush: null,
minimumAccessLevelForDelete: null,
},
__typename: 'ContainerRepositoryTag',
},
{
@ -234,6 +242,10 @@ export const tagsMock = [
userPermissions: {
destroyContainerRepositoryTag: true,
},
protection: {
minimumAccessLevelForPush: null,
minimumAccessLevelForDelete: null,
},
__typename: 'ContainerRepositoryTag',
},
{
@ -251,6 +263,10 @@ export const tagsMock = [
userPermissions: {
destroyContainerRepositoryTag: true,
},
protection: {
minimumAccessLevelForPush: null,
minimumAccessLevelForDelete: null,
},
__typename: 'ContainerRepositoryTag',
},
];

View File

@ -1,4 +1,4 @@
import { GlLoadingIcon, GlKeysetPagination, GlModal } from '@gitlab/ui';
import { GlLoadingIcon, GlKeysetPagination, GlModal, GlTable } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
@ -120,7 +120,7 @@ describe('Container protection repository rules project settings', () => {
});
await waitForPromises();
expect(findTable().exists()).toBe(false);
expect(wrapper.findComponent(GlTable).exists()).toBe(false);
expect(findEmptyText().exists()).toBe(true);
});
@ -352,7 +352,7 @@ describe('Container protection repository rules project settings', () => {
expect(findComboboxInTableRow(0).props('value')).toBe(accessLevelValueMaintainer);
expect(findComboboxInTableRow(1).props('value')).toBe(accessLevelValueMaintainer);
await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
await findComboboxInTableRow(0).findAll('option').at(1).setSelected();
expect(findComboboxInTableRow(0).props('value')).toBe(accessLevelValueOwner);
expect(findComboboxInTableRow(1).props('value')).toBe(accessLevelValueMaintainer);
@ -367,7 +367,7 @@ describe('Container protection repository rules project settings', () => {
await waitForPromises();
await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
await findComboboxInTableRow(0).findAll('option').at(1).setSelected();
expect(updateContainerProtectionRepositoryRuleMutationResolver).toHaveBeenCalledTimes(
1,
@ -385,7 +385,7 @@ describe('Container protection repository rules project settings', () => {
await waitForPromises();
await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
await findComboboxInTableRow(0).findAll('option').at(1).setSelected();
expect(findComboboxInTableRow(0).props('disabled')).toBe(true);
expect(findTableRowButtonDelete(0).attributes('disabled')).toBe('disabled');
@ -411,7 +411,7 @@ describe('Container protection repository rules project settings', () => {
await waitForPromises();
await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
await findComboboxInTableRow(0).findAll('option').at(1).setSelected();
await waitForPromises();
@ -434,7 +434,7 @@ describe('Container protection repository rules project settings', () => {
await waitForPromises();
await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
await findComboboxInTableRow(0).findAll('option').at(1).setSelected();
await waitForPromises();
@ -447,7 +447,7 @@ describe('Container protection repository rules project settings', () => {
await waitForPromises();
await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
await findComboboxInTableRow(0).findAll('option').at(1).setSelected();
await waitForPromises();

View File

@ -5,14 +5,18 @@ import { GlForm } from '@gitlab/ui';
import createContainerProtectionTagRuleMutationPayload from 'test_fixtures/graphql/packages_and_registries/settings/project/graphql/mutations/create_container_protection_tag_rule.mutation.graphql.json';
import createContainerProtectionTagRuleMutationErrorPayload from 'test_fixtures/graphql/packages_and_registries/settings/project/graphql/mutations/create_container_protection_tag_rule.mutation.graphql.errors.json';
import createContainerProtectionTagRuleMutationServerErrorPayload from 'test_fixtures/graphql/packages_and_registries/settings/project/graphql/mutations/create_container_protection_tag_rule.mutation.graphql.server_errors.json';
import updateContainerProtectionTagRuleMutationPayload from 'test_fixtures/graphql/packages_and_registries/settings/project/graphql/mutations/update_container_protection_tag_rule.mutation.graphql.json';
import updateContainerProtectionTagRuleMutationErrorPayload from 'test_fixtures/graphql/packages_and_registries/settings/project/graphql/mutations/update_container_protection_tag_rule.mutation.graphql.errors.json';
import updateContainerProtectionTagRuleMutationServerErrorPayload from 'test_fixtures/graphql/packages_and_registries/settings/project/graphql/mutations/update_container_protection_tag_rule.mutation.graphql.server_errors.json';
import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import ContainerProtectionTagRuleForm from '~/packages_and_registries/settings/project/components/container_protection_tag_rule_form.vue';
import createContainerProtectionTagRuleMutation from '~/packages_and_registries/settings/project/graphql/mutations/create_container_protection_tag_rule.mutation.graphql';
import updateContainerProtectionTagRuleMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_container_protection_tag_rule.mutation.graphql';
import { createContainerProtectionTagRuleMutationInput } from '../mock_data';
import { containerProtectionTagRuleMutationInput } from '../mock_data';
Vue.use(VueApollo);
@ -24,6 +28,10 @@ describe('container Protection Rule Form', () => {
projectPath: 'path',
};
const rule =
createContainerProtectionTagRuleMutationPayload.data.createContainerProtectionTagRule
.containerProtectionTagRule;
const findForm = () => wrapper.findComponent(GlForm);
const findTagNamePatternInput = () =>
wrapper.findByRole('textbox', { name: /protect container tags matching/i });
@ -31,24 +39,36 @@ describe('container Protection Rule Form', () => {
wrapper.findByRole('combobox', { name: /minimum role allowed to push/i });
const findMinimumAccessLevelForDeleteSelect = () =>
wrapper.findByRole('combobox', { name: /minimum role allowed to delete/i });
const findSubmitButton = () => wrapper.findByTestId('add-rule-btn');
const findCancelButton = () => wrapper.findByRole('button', { name: /cancel/i });
const findSubmitButton = () => wrapper.findByTestId('submit-btn');
const mountComponent = ({ config, provide = defaultProvidedValues } = {}) => {
const mountComponent = ({ config, provide = defaultProvidedValues, props } = {}) => {
wrapper = mountExtended(ContainerProtectionTagRuleForm, {
propsData: props,
provide,
...config,
});
};
const mountComponentWithApollo = ({
props = {},
provide = defaultProvidedValues,
mutationResolver = jest.fn().mockResolvedValue(createContainerProtectionTagRuleMutationPayload),
createMutationResolver = jest
.fn()
.mockResolvedValue(createContainerProtectionTagRuleMutationPayload),
updateMutationResolver = jest
.fn()
.mockResolvedValue(updateContainerProtectionTagRuleMutationPayload),
} = {}) => {
const requestHandlers = [[createContainerProtectionTagRuleMutation, mutationResolver]];
const requestHandlers = [
[createContainerProtectionTagRuleMutation, createMutationResolver],
[updateContainerProtectionTagRuleMutation, updateMutationResolver],
];
fakeApollo = createMockApollo(requestHandlers);
mountComponent({
props,
provide,
config: {
apolloProvider: fakeApollo,
@ -65,18 +85,18 @@ describe('container Protection Rule Form', () => {
});
describe.each`
tagNamePattern | errorMessage
${''} | ${'This field is required.'}
${' '} | ${'This field is required.'}
${createContainerProtectionTagRuleMutationInput.tagNamePattern.repeat(100)} | ${'Must be less than 100 characters.'}
tagNamePattern | errorMessage
${''} | ${'This field is required.'}
${' '} | ${'This field is required.'}
${containerProtectionTagRuleMutationInput.tagNamePattern.repeat(100)} | ${'Must be less than 100 characters.'}
`('when tagNamePattern is "$tagNamePattern"', ({ tagNamePattern, errorMessage }) => {
const mutationResolver = jest
const createMutationResolver = jest
.fn()
.mockResolvedValue(createContainerProtectionTagRuleMutationPayload);
beforeEach(async () => {
mountComponentWithApollo({
mutationResolver,
createMutationResolver,
});
await findTagNamePatternInput().setValue(tagNamePattern);
@ -89,7 +109,7 @@ describe('container Protection Rule Form', () => {
it('when submitted does not make graphql request', async () => {
await findForm().trigger('submit');
expect(mutationResolver).not.toHaveBeenCalled();
expect(createMutationResolver).not.toHaveBeenCalled();
});
});
});
@ -133,7 +153,7 @@ describe('container Protection Rule Form', () => {
mountComponentWithApollo();
await findTagNamePatternInput().setValue(
createContainerProtectionTagRuleMutationInput.tagNamePattern,
containerProtectionTagRuleMutationInput.tagNamePattern,
);
findForm().trigger('submit');
});
@ -144,27 +164,51 @@ describe('container Protection Rule Form', () => {
});
});
describe.each`
description | props | submitButtonText
${'when form has no prop "rule"'} | ${{}} | ${'Add rule'}
${'when form has prop "rule"'} | ${{ rule }} | ${'Save changes'}
`('$description', ({ props, submitButtonText }) => {
beforeEach(() => {
mountComponent({
props,
});
});
describe('submit button', () => {
it(`renders text: ${submitButtonText}`, () => {
expect(findSubmitButton().text()).toBe(submitButtonText);
});
});
describe('cancel button', () => {
it('renders with text: "Cancel"', () => {
expect(findCancelButton().text()).toBe('Cancel');
});
});
});
describe('form events', () => {
describe('reset', () => {
const mutationResolver = jest
const createMutationResolver = jest
.fn()
.mockResolvedValue(createContainerProtectionTagRuleMutationPayload);
beforeEach(() => {
mountComponentWithApollo({ mutationResolver });
mountComponentWithApollo({ createMutationResolver });
findForm().trigger('reset');
});
it('emits custom event "cancel"', () => {
expect(mutationResolver).not.toHaveBeenCalled();
expect(createMutationResolver).not.toHaveBeenCalled();
expect(wrapper.emitted('cancel')).toBeDefined();
expect(wrapper.emitted('cancel')[0]).toEqual([]);
});
it('does not dispatch apollo mutation request', () => {
expect(mutationResolver).not.toHaveBeenCalled();
expect(createMutationResolver).not.toHaveBeenCalled();
});
it('does not emit custom event "submit"', () => {
@ -172,29 +216,31 @@ describe('container Protection Rule Form', () => {
});
});
describe('submit', () => {
describe('submit a new rule', () => {
const findAlert = () => extendedWrapper(wrapper.findByRole('alert'));
const submitForm = () => {
findTagNamePatternInput().setValue(
createContainerProtectionTagRuleMutationInput.tagNamePattern,
);
findTagNamePatternInput().setValue(containerProtectionTagRuleMutationInput.tagNamePattern);
findForm().trigger('submit');
return waitForPromises();
};
it('dispatches correct apollo mutation', async () => {
const mutationResolver = jest
const createMutationResolver = jest
.fn()
.mockResolvedValue(createContainerProtectionTagRuleMutationPayload);
const updateMutationResolver = jest
.fn()
.mockResolvedValue(updateContainerProtectionTagRuleMutationPayload);
mountComponentWithApollo({ mutationResolver });
mountComponentWithApollo({ createMutationResolver, updateMutationResolver });
await submitForm();
expect(mutationResolver).toHaveBeenCalledWith({
input: { projectPath: 'path', ...createContainerProtectionTagRuleMutationInput },
expect(createMutationResolver).toHaveBeenCalledWith({
input: { projectPath: 'path', ...containerProtectionTagRuleMutationInput },
});
expect(updateMutationResolver).not.toHaveBeenCalled();
});
it('emits event "submit" when apollo mutation successful', async () => {
@ -212,16 +258,89 @@ describe('container Protection Rule Form', () => {
});
describe.each`
description | mutationResolver | expectedErrorMessage
description | createMutationResolver | expectedErrorMessage
${'responds with field errors'} | ${jest.fn().mockResolvedValue(createContainerProtectionTagRuleMutationErrorPayload)} | ${'Tag name pattern has already been taken'}
${'responds with server errors'} | ${jest.fn().mockResolvedValue(createContainerProtectionTagRuleMutationServerErrorPayload)} | ${"tagNamePattern can't be blank"}
${'fails with network error'} | ${jest.fn().mockRejectedValue(new Error('GraphQL error'))} | ${'Something went wrong while saving the protection rule.'}
`(
'when apollo mutation request $description',
({ mutationResolver, expectedErrorMessage }) => {
({ createMutationResolver, expectedErrorMessage }) => {
beforeEach(async () => {
mountComponentWithApollo({
mutationResolver,
createMutationResolver,
});
await submitForm();
});
it('shows error alert with correct message', () => {
expect(findAlert().text()).toBe(expectedErrorMessage);
});
},
);
});
describe('updating existing rule', () => {
const findAlert = () => extendedWrapper(wrapper.findByRole('alert'));
const findDeleteCombobox = () => extendedWrapper(findMinimumAccessLevelForDeleteSelect());
const submitForm = async () => {
findTagNamePatternInput().setValue(containerProtectionTagRuleMutationInput.tagNamePattern);
await findDeleteCombobox().findAll('option').at(0).setSelected();
findForm().trigger('submit');
return waitForPromises();
};
it('dispatches correct apollo mutation', async () => {
const createMutationResolver = jest
.fn()
.mockResolvedValue(createContainerProtectionTagRuleMutationPayload);
const updateMutationResolver = jest
.fn()
.mockResolvedValue(updateContainerProtectionTagRuleMutationPayload);
mountComponentWithApollo({
createMutationResolver,
updateMutationResolver,
props: { rule },
});
await submitForm();
expect(createMutationResolver).not.toHaveBeenCalled();
expect(updateMutationResolver).toHaveBeenCalledWith({
input: { id: rule.id, ...containerProtectionTagRuleMutationInput },
});
});
it('emits event "submit" when apollo mutation successful', async () => {
mountComponentWithApollo({
props: { rule },
});
await submitForm();
expect(wrapper.emitted('submit')).toBeDefined();
const expectedEventSubmitPayload =
updateContainerProtectionTagRuleMutationPayload.data.updateContainerProtectionTagRule
.containerProtectionTagRule;
expect(wrapper.emitted('submit')[0]).toEqual([expectedEventSubmitPayload]);
expect(wrapper.emitted()).not.toHaveProperty('cancel');
});
describe.each`
description | updateMutationResolver | expectedErrorMessage
${'responds with field errors'} | ${jest.fn().mockResolvedValue(updateContainerProtectionTagRuleMutationErrorPayload)} | ${'Tag name pattern has already been taken'}
${'responds with server errors'} | ${jest.fn().mockResolvedValue(updateContainerProtectionTagRuleMutationServerErrorPayload)} | ${"tagNamePattern can't be blank"}
${'fails with network error'} | ${jest.fn().mockRejectedValue(new Error('GraphQL error'))} | ${'Something went wrong while saving the protection rule.'}
`(
'when apollo mutation request $description',
({ updateMutationResolver, expectedErrorMessage }) => {
beforeEach(async () => {
mountComponentWithApollo({
props: { rule },
updateMutationResolver,
});
await submitForm();

View File

@ -137,64 +137,6 @@ describe('ContainerProtectionTagRules', () => {
expect.objectContaining({ projectPath: defaultProvidedValues.projectPath, first: 5 }),
);
});
describe('when `Add protection rule` button is clicked', () => {
beforeEach(async () => {
await findCrudComponent().vm.$emit('showForm');
});
it('opens drawer', () => {
expect(findDrawer().props('open')).toBe(true);
expect(findDrawerTitle().text()).toBe('Add protection rule');
});
it('renders form', () => {
expect(findForm().exists()).toBe(true);
});
describe('when drawer emits `close` event', () => {
beforeEach(async () => {
await findDrawer().vm.$emit('close');
});
it('closes drawer', () => {
expect(findDrawer().props('open')).toBe(false);
});
});
describe('when form emits `cancel` event', () => {
beforeEach(async () => {
await findForm().vm.$emit('cancel');
});
it('closes drawer', () => {
expect(findDrawer().props('open')).toBe(false);
});
});
describe('when form emits `submit` event', () => {
it('refetches protection rules after successful graphql mutation', async () => {
const containerProtectionTagRuleQueryResolver = jest
.fn()
.mockResolvedValue(containerProtectionTagRuleQueryPayload);
createComponent({
containerProtectionTagRuleQueryResolver,
});
await waitForPromises();
expect(containerProtectionTagRuleQueryResolver).toHaveBeenCalledTimes(1);
await findCrudComponent().vm.$emit('showForm');
await findForm().vm.$emit('submit');
expect(findDrawer().props('open')).toBe(false);
expect(containerProtectionTagRuleQueryResolver).toHaveBeenCalledTimes(2);
expect($toast.show).toHaveBeenCalledWith('Container protection rule created.');
});
});
});
});
describe('when data is loaded & contains tag protection rules', () => {
@ -203,6 +145,7 @@ describe('ContainerProtectionTagRules', () => {
const findTableBody = () => extendedWrapper(findTable().findAllByRole('rowgroup').at(1));
const findTableRow = (i) => extendedWrapper(findTableBody().findAllByRole('row').at(i));
const findTableRowCell = (i, j) => extendedWrapper(findTableRow(i).findAllByRole('cell').at(j));
const findTableRowButtonEdit = (i) => findTableRow(i).findByRole('button', { name: /edit/i });
const findTableRowButtonDelete = (i) =>
findTableRow(i).findByRole('button', { name: /delete/i });
@ -281,15 +224,21 @@ describe('ContainerProtectionTagRules', () => {
});
describe('column "rowActions"', () => {
describe('button "Delete"', () => {
describe.each`
buttonName | buttonFinder
${'Edit'} | ${findTableRowButtonEdit}
${'Delete'} | ${findTableRowButtonDelete}
`('button "$buttonName"', ({ buttonFinder }) => {
it('exists in table', () => {
expect(findTableRowButtonDelete(0).exists()).toBe(true);
expect(buttonFinder(0).exists()).toBe(true);
});
describe('when button is clicked', () => {
it('renders the "delete container protection rule" confirmation modal', async () => {
await findTableRowButtonDelete(0).trigger('click');
beforeEach(async () => {
await buttonFinder(0).trigger('click');
});
it('renders the "delete container protection rule" confirmation modal', () => {
const modalId = getBinding(findTableRowButtonDelete(0).element, 'gl-modal');
expect(findModal().props('modal-id')).toBe(modalId);
@ -426,6 +375,77 @@ describe('ContainerProtectionTagRules', () => {
});
});
});
describe.each`
description | beforeFn | title | toastMessage
${'when `Add protection rule` button is clicked'} | ${() => findCrudComponent().vm.$emit('showForm')} | ${'Add protection rule'} | ${'Container protection rule created.'}
${'when `Edit` button for a rule is clicked'} | ${() => findTableRowButtonEdit(0).trigger('click')} | ${'Edit protection rule'} | ${'Container protection rule updated.'}
`('$description', ({ beforeFn, title, toastMessage }) => {
beforeEach(async () => {
createComponent({
mountFn: mountExtended,
});
await waitForPromises();
await beforeFn();
});
it('opens drawer', () => {
expect(findDrawer().props('open')).toBe(true);
});
it(`sets the appropriate drawer title: ${title}`, () => {
expect(findDrawerTitle().text()).toBe(title);
});
it('renders form', () => {
expect(findForm().exists()).toBe(true);
});
describe('when drawer emits `close` event', () => {
beforeEach(async () => {
await findDrawer().vm.$emit('close');
});
it('closes drawer', () => {
expect(findDrawer().props('open')).toBe(false);
});
});
describe('when form emits `cancel` event', () => {
beforeEach(async () => {
await findForm().vm.$emit('cancel');
});
it('closes drawer', () => {
expect(findDrawer().props('open')).toBe(false);
});
});
describe('when form emits `submit` event', () => {
it('refetches protection rules after successful graphql mutation', async () => {
const containerProtectionTagRuleQueryResolver = jest
.fn()
.mockResolvedValue(containerProtectionTagRuleQueryPayload);
createComponent({
containerProtectionTagRuleQueryResolver,
mountFn: mountExtended,
});
await waitForPromises();
expect(containerProtectionTagRuleQueryResolver).toHaveBeenCalledTimes(1);
await beforeFn();
await findForm().vm.$emit('submit');
expect(findDrawer().props('open')).toBe(false);
expect(containerProtectionTagRuleQueryResolver).toHaveBeenCalledTimes(2);
expect($toast.show).toHaveBeenCalledWith(toastMessage);
});
});
});
});
describe('when data is loaded & contains maximum number of tag protection rules', () => {

View File

@ -1,4 +1,4 @@
import { GlLoadingIcon, GlKeysetPagination, GlModal } from '@gitlab/ui';
import { GlLoadingIcon, GlKeysetPagination, GlModal, GlTable } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
@ -114,7 +114,7 @@ describe('Packages protection rules project settings', () => {
});
await waitForPromises();
expect(findTable().exists()).toBe(false);
expect(wrapper.findComponent(GlTable).exists()).toBe(false);
expect(findEmptyText().exists()).toBe(true);
});
@ -328,11 +328,11 @@ describe('Packages protection rules project settings', () => {
await waitForPromises();
expect(findComboboxInTableRow(0).props('value')).toBe(accessLevelValueMaintainer);
await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
await findComboboxInTableRow(0).findAll('option').at(1).setSelected();
expect(findComboboxInTableRow(0).props('value')).toBe(accessLevelValueOwner);
expect(findComboboxInTableRow(1).props('value')).toBe(accessLevelValueMaintainer);
await findComboboxInTableRow(1).setValue(accessLevelValueAdmin);
await findComboboxInTableRow(1).findAll('option').at(2).setSelected();
expect(findComboboxInTableRow(1).props('value')).toBe(accessLevelValueAdmin);
expect(findComboboxInTableRow(0).props('value')).toBe(accessLevelValueOwner);
@ -347,7 +347,7 @@ describe('Packages protection rules project settings', () => {
await waitForPromises();
await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
await findComboboxInTableRow(0).findAll('option').at(1).setSelected();
expect(updatePackagesProtectionRuleMutationResolver).toHaveBeenCalledTimes(1);
expect(updatePackagesProtectionRuleMutationResolver).toHaveBeenCalledWith({
@ -363,7 +363,7 @@ describe('Packages protection rules project settings', () => {
await waitForPromises();
await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
await findComboboxInTableRow(0).findAll('option').at(1).setSelected();
expect(findComboboxInTableRow(0).props('disabled')).toBe(true);
expect(findComboboxInTableRow(1).props('disabled')).toBe(false);
@ -379,7 +379,7 @@ describe('Packages protection rules project settings', () => {
await waitForPromises();
await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
await findComboboxInTableRow(0).findAll('option').at(1).setSelected();
expect(findComboboxInTableRow(0).props('disabled')).toBe(true);
expect(findTableRowButtonDelete(0).props('disabled')).toBe(true);
@ -399,7 +399,7 @@ describe('Packages protection rules project settings', () => {
await waitForPromises();
await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
await findComboboxInTableRow(0).findAll('option').at(1).setSelected();
await waitForPromises();
@ -420,7 +420,7 @@ describe('Packages protection rules project settings', () => {
await waitForPromises();
await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
await findComboboxInTableRow(0).findAll('option').at(1).setSelected();
await waitForPromises();
@ -433,7 +433,7 @@ describe('Packages protection rules project settings', () => {
await waitForPromises();
await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
await findComboboxInTableRow(0).findAll('option').at(1).setSelected();
await waitForPromises();

View File

@ -266,8 +266,8 @@ export const updateContainerProtectionRepositoryRuleMutationPayload = ({
},
});
export const createContainerProtectionTagRuleMutationInput = {
tagNamePattern: `v.*`,
export const containerProtectionTagRuleMutationInput = {
tagNamePattern: 'v.+',
minimumAccessLevelForPush: 'MAINTAINER',
minimumAccessLevelForDelete: 'MAINTAINER',
};

View File

@ -0,0 +1,77 @@
import { GlDisclosureDropdown } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import CompactCodeDropdown from '~/repository/components/code_dropdown/compact_code_dropdown.vue';
import CodeDropdownItem from '~/vue_shared/components/code_dropdown/code_dropdown_item.vue';
describe('Compact Code Dropdown coomponent', () => {
let wrapper;
const sshUrl = 'ssh://foo.bar';
const httpUrl = 'http://foo.bar';
const httpsUrl = 'https://foo.bar';
const defaultPropsData = {
sshUrl,
httpUrl,
};
const findCodeDropdownItems = () => wrapper.findAllComponents(CodeDropdownItem);
const findCodeDropdownItemAtIndex = (index) => findCodeDropdownItems().at(index);
const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
const createComponent = (propsData = defaultPropsData) => {
wrapper = shallowMount(CompactCodeDropdown, {
propsData,
});
};
describe('copyGroup', () => {
describe('rendering', () => {
it.each`
name | index | link
${'SSH'} | ${0} | ${sshUrl}
${'HTTP'} | ${1} | ${httpUrl}
`('renders correct link and a copy-button for $name', ({ index, link }) => {
createComponent();
const item = findCodeDropdownItemAtIndex(index);
expect(item.props('link')).toBe(link);
});
it.each`
name | value
${'sshUrl'} | ${sshUrl}
${'httpUrl'} | ${httpUrl}
`('does not fail if only $name is set', ({ name, value }) => {
createComponent({ [name]: value });
expect(findCodeDropdownItemAtIndex(0).props('link')).toBe(value);
});
});
describe('functionality', () => {
it.each`
name | value
${'sshUrl'} | ${null}
${'httpUrl'} | ${null}
`('allows null values for the props', ({ name, value }) => {
createComponent({ ...defaultPropsData, [name]: value });
expect(findCodeDropdownItems().length).toBe(1);
});
it('correctly calculates httpLabel for HTTPS protocol', () => {
createComponent({ httpUrl: httpsUrl });
expect(findCodeDropdownItemAtIndex(0).attributes('label')).toContain('HTTPS');
});
it.each`
name | index | link
${'SSH'} | ${0} | ${sshUrl}
${'HTTP'} | ${1} | ${httpUrl}
`('does not close dropdown on $name item click', () => {
createComponent();
expect(findDropdown().props('autoClose')).toBe(false);
});
});
});
});

View File

@ -4,12 +4,12 @@ import {
GlDisclosureDropdownGroup,
GlDisclosureDropdownItem,
} from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { stubComponent } from 'helpers/stub_component';
import CodeDropdown from '~/vue_shared/components/code_dropdown/code_dropdown.vue';
import CodeDropdownItem from '~/vue_shared/components/code_dropdown/code_dropdown_item.vue';
describe('Clone Dropdown Button', () => {
describe('Code Dropdown component', () => {
let wrapper;
const sshUrl = 'ssh://foo.bar';
const httpUrl = 'http://foo.bar';
@ -40,7 +40,7 @@ describe('Clone Dropdown Button', () => {
const closeDropdown = jest.fn();
const createComponent = (propsData = defaultPropsData) => {
wrapper = shallowMount(CodeDropdown, {
wrapper = shallowMountExtended(CodeDropdown, {
propsData,
stubs: {
GlFormInputGroup,

View File

@ -148,7 +148,6 @@ describe('WorkItemDetail component', () => {
mutationHandler,
error = undefined,
workItemsAlphaEnabled = false,
namespaceLevelWorkItems = true,
hasSubepicsFeature = true,
router = true,
modalIsGroup = null,
@ -184,7 +183,6 @@ describe('WorkItemDetail component', () => {
provide: {
glFeatures: {
workItemsAlpha: workItemsAlphaEnabled,
namespaceLevelWorkItems,
},
hasSubepicsFeature,
fullPath: 'group/project',
@ -429,17 +427,6 @@ describe('WorkItemDetail component', () => {
expect(findWorkItemType().classes()).toEqual(['sm:!gl-block', 'gl-w-full']);
});
describe('`namespace_level_work_items` is disabled', () => {
it('does not show ancestors widget and shows title in the header', async () => {
createComponent({ namespaceLevelWorkItems: false });
await waitForPromises();
expect(findAncestors().exists()).toBe(false);
expect(findWorkItemType().classes()).toEqual(['sm:!gl-block', 'gl-w-full']);
});
});
describe('`subepics` is unavailable', () => {
it('does not show ancestors widget and shows title in the header', async () => {
const epicWorkItem = workItemByIidResponseFactory({

View File

@ -686,9 +686,9 @@ RSpec.describe SidebarsHelper, feature_category: :navigation do
end
describe 'admin user' do
it 'returns Admin Panel for admin nav', :aggregate_failures do
allow(user).to receive(:can_admin_all_resources?).and_return(true)
let(:user) { build(:admin) }
it 'returns Admin Panel for admin nav', :enable_admin_mode do
expect(helper.super_sidebar_nav_panel(nav: 'admin', user: user)).to be_a(Sidebars::Admin::Panel)
end
end

View File

@ -3,8 +3,8 @@
require 'spec_helper'
RSpec.describe Gitlab::BackgroundMigration::FixUsernamespaceAuditEvents, feature_category: :audit_events do
let(:audit_events_table) { table(:audit_events) }
let(:instance_audit_events_table) { table(:instance_audit_events) }
let(:audit_events_table) { partitioned_table(:audit_events) }
let(:instance_audit_events_table) { partitioned_table(:instance_audit_events) }
let!(:usernamespace_audit_event) do
audit_events_table.create!(

View File

@ -25,7 +25,6 @@ RSpec.describe 'new tables missing sharding_key', feature_category: :cell do
*tables_with_alternative_not_null_constraint,
'analytics_devops_adoption_segments.namespace_id',
*['badges.project_id', 'badges.group_id'],
*['boards.project_id', 'boards.group_id'],
'ci_pipeline_schedules.project_id',
'ci_sources_pipelines.project_id',
'ci_triggers.project_id',

View File

@ -912,6 +912,7 @@ project:
- security_statistics
- vulnerability_management_policies
- project_control_compliance_statuses
- approval_policies
award_emoji:
- awardable
- user

View File

@ -0,0 +1,43 @@
# frozen_string_literal: true
require 'spec_helper'
require_migration!
RSpec.describe DeleteBoardsWithoutProjectAndGroup, migration: :gitlab_main, feature_category: :team_planning do
let(:organization) { table(:organizations).create!(name: 'organization', path: 'organization') }
let(:namespace) { table(:namespaces).create!(name: "namespace", path: "namespace", organization_id: organization.id) }
let(:project) do
table(:projects).create!(
namespace_id: namespace.id,
project_namespace_id: namespace.id,
organization_id: organization.id
)
end
let(:boards) { table(:boards) }
before do
stub_const("#{described_class}::BATCH_SIZE", 2)
boards.create!
boards.create!
boards.create!
boards.create!
boards.create!(group_id: namespace.id)
end
describe '#up' do
it 'updates records in batches' do
expect do
migrate!
end.to make_queries_matching(
/DELETE FROM "boards".+WHERE \(group_id IS NULL AND project_id IS NULL\)/,
2
)
end
it 'deletes offending records records' do
expect { migrate! }.to change { boards.count }.from(5).to(1)
end
end
end

View File

@ -0,0 +1,51 @@
# frozen_string_literal: true
require 'spec_helper'
require_migration!
RSpec.describe FixBoardsWithProjectAndGroup, migration: :gitlab_main, feature_category: :team_planning do
let(:organization) { table(:organizations).create!(name: 'organization', path: 'organization') }
let(:namespace) { table(:namespaces).create!(name: "namespace", path: "namespace", organization_id: organization.id) }
let(:project) do
table(:projects).create!(
namespace_id: namespace.id,
project_namespace_id: namespace.id,
organization_id: organization.id
)
end
let!(:board1) { table(:boards).create!(group_id: namespace.id, project_id: project.id) }
let!(:board2) { table(:boards).create!(group_id: namespace.id, project_id: project.id) }
let!(:board3) { table(:boards).create!(group_id: namespace.id, project_id: project.id) }
let!(:board4) { table(:boards).create!(group_id: namespace.id, project_id: project.id) }
let!(:board5) { table(:boards).create!(group_id: namespace.id) }
before do
stub_const("#{described_class}::BATCH_SIZE", 2)
end
describe '#up' do
it 'updates records in batches' do
expect do
migrate!
end.to make_queries_matching(/UPDATE\s+"boards"/, 2)
end
it 'removes group_id from offending records' do
expect { migrate! }.to change {
[board1, board2, board3, board4].each(&:reload).pluck(:project_id, :group_id)
}.from(
Array.new(4) { [project.id, namespace.id] }
).to(
[
[project.id, nil],
[project.id, nil],
[project.id, nil],
[project.id, nil]
]
).and(
not_change { board5.reload.group_id }.from(namespace.id)
)
end
end
end

View File

@ -20,6 +20,21 @@ RSpec.describe Board do
describe 'validations' do
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_presence_of(:project) }
describe 'group and project mutually exclusive' do
context 'when project is present' do
subject { described_class.new(project: project) }
it do
is_expected.to validate_absence_of(:group)
.with_message(_("can't be specified if a project was already provided"))
end
end
context 'when project is not present' do
it { is_expected.not_to validate_absence_of(:group) }
end
end
end
describe 'constants' do

View File

@ -3179,6 +3179,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
context 'when runner is assigned to build' do
let(:runner) { create(:ci_runner, description: 'description', tag_list: %w[docker linux]) }
let(:expected_tags_value) { %w[docker linux].to_s }
before do
build.update!(runner: runner)
@ -3186,7 +3187,13 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
it { is_expected.to include({ key: 'CI_RUNNER_ID', value: runner.id.to_s, public: true, masked: false }) }
it { is_expected.to include({ key: 'CI_RUNNER_DESCRIPTION', value: 'description', public: true, masked: false }) }
it { is_expected.to include({ key: 'CI_RUNNER_TAGS', value: 'docker, linux', public: true, masked: false }) }
it { is_expected.to include({ key: 'CI_RUNNER_TAGS', value: expected_tags_value, public: true, masked: false }) }
context 'when the tags are preloaded' do
subject { described_class.preload(:tags).find(build.id).variables }
it { is_expected.to include({ key: 'CI_RUNNER_TAGS', value: expected_tags_value, public: true, masked: false }) }
end
end
context 'when build is for a deployment' do

View File

@ -4,7 +4,7 @@ RSpec.shared_examples 'Admin menu' do |link:, title:, icon:, separated: false|
let_it_be(:user) { build(:user, :admin) }
before do
allow(user).to receive(:can_admin_all_resources?).and_return(true)
stub_application_setting(admin_mode: false)
end
let(:context) { Sidebars::Context.new(current_user: user, container: nil) }