Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2025-01-17 00:39:10 +00:00
parent d861e5b4ca
commit accfc89b9a
52 changed files with 1011 additions and 105 deletions

View File

@ -1 +1 @@
dd6ef2a257606d4499cab51c80966a474f1a840f
f8f71797e1e8a1ad0e451becfe5294578e28894d

View File

@ -381,6 +381,8 @@ export default {
},
resetForm() {
this.variable = { ...defaultVariableState };
this.visibility = VISIBILITY_VISIBLE;
},
setEnvironmentScope(scope) {
this.variable = { ...this.variable, environmentScope: scope };

View File

@ -9,7 +9,7 @@ import { getDiffMode } from '~/diffs/store/utils';
import { diffViewerModes } from '~/ide/constants';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import { isCollapsed } from '~/diffs/utils/diff_file';
import { FILE_DIFF_POSITION_TYPE } from '~/diffs/constants';
import { FILE_DIFF_POSITION_TYPE, IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants';
const FIRST_CHAR_REGEX = /^(\+|-| )/;
@ -69,11 +69,21 @@ export default {
return this.discussion.position?.position_type;
},
isFileDiscussion() {
if (!this.discussion.diff_file) {
return (
this.discussion.original_position.position_type === IMAGE_DIFF_POSITION_TYPE ||
this.discussion.original_position.position_type === FILE_DIFF_POSITION_TYPE
);
}
return this.positionType === FILE_DIFF_POSITION_TYPE;
},
showHeader() {
if (this.discussion.diff_file) return true;
return (
this.discussion.diff_file || this.discussion.original_position.position_type === 'file'
this.discussion.original_position.position_type === FILE_DIFF_POSITION_TYPE ||
this.discussion.original_position.position_type === IMAGE_DIFF_POSITION_TYPE
);
},
backfilledDiffFile() {

View File

@ -13,6 +13,7 @@ import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import { detectAndConfirmSensitiveTokens } from '~/lib/utils/secret_detection';
import { FILE_DIFF_POSITION_TYPE, IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants';
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
@ -163,9 +164,22 @@ export default {
},
canShowReplyActions() {
if (this.shouldRenderDiffs) {
if (this.discussion.diff_file?.diff_refs) {
return true;
}
/*
* https://gitlab.com/gitlab-com/gl-infra/production/-/issues/19118
*
* For most diff discussions we should have a `diff_file`.
* However in some cases we might we might not have this object.
* In these we need to check if the `original_position.position_type`
* is either a file or an image, doing this allows us to still
* render the reply actions.
*/
return (
this.discussion.original_position.position_type === 'file' ||
this.discussion.diff_file?.diff_refs
this.discussion.original_position?.position_type === FILE_DIFF_POSITION_TYPE ||
this.discussion.original_position?.position_type === IMAGE_DIFF_POSITION_TYPE
);
}

View File

@ -0,0 +1,100 @@
<script>
import { GlFormGroup, GlForm, GlFormInput, GlFormSelect, GlLink, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
const GRAPHQL_ACCESS_LEVEL_VALUE_MAINTAINER = 'MAINTAINER';
const GRAPHQL_ACCESS_LEVEL_VALUE_OWNER = 'OWNER';
const GRAPHQL_ACCESS_LEVEL_VALUE_ADMIN = 'ADMIN';
export default {
components: {
GlForm,
GlFormGroup,
GlFormInput,
GlFormSelect,
GlLink,
GlSprintf,
},
inject: ['projectPath'],
data() {
return {
protectionRuleFormData: {
tagNamePattern: '',
minimumAccessLevelForPush: GRAPHQL_ACCESS_LEVEL_VALUE_MAINTAINER,
minimumAccessLevelForDelete: GRAPHQL_ACCESS_LEVEL_VALUE_MAINTAINER,
},
};
},
minimumAccessLevelOptions: [
{ value: GRAPHQL_ACCESS_LEVEL_VALUE_MAINTAINER, text: __('Maintainer') },
{ value: GRAPHQL_ACCESS_LEVEL_VALUE_OWNER, text: __('Owner') },
{ value: GRAPHQL_ACCESS_LEVEL_VALUE_ADMIN, text: __('Admin') },
],
};
</script>
<template>
<gl-form>
<gl-form-group
:label="s__('ContainerRegistry|Protect container tags matching')"
label-for="input-tag-name-pattern"
>
<gl-form-input
id="input-tag-name-pattern"
v-model.trim="protectionRuleFormData.tagNamePattern"
type="text"
/>
<template #description>
<gl-sprintf
:message="
s__(
'ContainerRegistry|Tags with names that match this regex pattern are protected. Must be less than 100 characters. %{linkStart}What regex patterns are supported?%{linkEnd}',
)
"
>
<template #link="{ content }">
<gl-link href="https://github.com/google/re2/wiki/syntax" target="_blank">{{
content
}}</gl-link>
</template>
</gl-sprintf>
</template>
</gl-form-group>
<gl-form-group
:label="s__('ContainerRegistry|Minimum role allowed to push')"
label-for="input-minimum-access-level-for-push"
>
<gl-form-select
id="input-minimum-access-level-for-push"
v-model="protectionRuleFormData.minimumAccessLevelForPush"
:options="$options.minimumAccessLevelOptions"
/>
<template #description>
{{
s__(
'ContainerRegistry|Only users with at least this role can push tags with a name that matches the protection rule.',
)
}}
</template>
</gl-form-group>
<gl-form-group
:label="s__('ContainerRegistry|Minimum role allowed to delete')"
label-for="input-minimum-access-level-for-delete"
>
<gl-form-select
id="input-minimum-access-level-for-delete"
v-model="protectionRuleFormData.minimumAccessLevelForDelete"
:options="$options.minimumAccessLevelOptions"
/>
<template #description>
{{
s__(
'ContainerRegistry|Only users with at least this role can delete tags with a name that matches the protection rule.',
)
}}
</template>
</gl-form-group>
</gl-form>
</template>

View File

@ -3,6 +3,7 @@ import {
GlAlert,
GlBadge,
GlButton,
GlDrawer,
GlLoadingIcon,
GlModal,
GlModalDirective,
@ -11,6 +12,7 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import CrudComponent from '~/vue_shared/components/crud_component.vue';
import ContainerProtectionTagRuleForm from '~/packages_and_registries/settings/project/components/container_protection_tag_rule_form.vue';
import getContainerProtectionTagRulesQuery from '~/packages_and_registries/settings/project/graphql/queries/get_container_protection_tag_rules.query.graphql';
import deleteContainerProtectionTagRuleMutation from '~/packages_and_registries/settings/project/graphql/mutations/delete_container_protection_tag_rule.mutation.graphql';
import { __, s__ } from '~/locale';
@ -22,10 +24,12 @@ const I18N_MINIMUM_ACCESS_LEVEL_TO_DELETE = s__('ContainerRegistry|Minimum acces
export default {
components: {
ContainerProtectionTagRuleForm,
CrudComponent,
GlAlert,
GlBadge,
GlButton,
GlDrawer,
GlLoadingIcon,
GlModal,
GlSprintf,
@ -63,6 +67,7 @@ export default {
protectionRuleMutationItem: null,
protectionRulesQueryPayload: { nodes: [], pageInfo: {} },
protectionRulesQueryPaginationParams: { first: MAX_LIMIT },
showDrawer: false,
};
},
computed: {
@ -98,6 +103,9 @@ export default {
clearAlertMessage() {
this.alertErrorMessage = '';
},
closeDrawer() {
this.showDrawer = false;
},
async deleteProtectionRule(protectionRule) {
this.clearAlertMessage();
@ -121,6 +129,9 @@ export default {
this.resetProtectionRuleMutation();
}
},
openDrawer() {
this.showDrawer = true;
},
refetchProtectionRules() {
this.$apollo.queries.protectionRulesQueryPayload.refetch();
},
@ -181,8 +192,11 @@ export default {
<template>
<crud-component
:collapsed="false"
:title="$options.i18n.title"
:toggle-text="s__('ContainerRegistry|Add protection rule')"
data-testid="project-container-protection-tag-rules-settings"
@showForm="openDrawer"
>
<template v-if="containsTableItems" #count>
<gl-badge>
@ -196,6 +210,7 @@ export default {
</gl-sprintf>
</gl-badge>
</template>
<template #default>
<p
class="gl-pb-0 gl-text-subtle"
@ -252,6 +267,18 @@ export default {
<p v-else data-testid="empty-text" class="gl-text-subtle">
{{ s__('ContainerRegistry|No container image tags are protected.') }}
</p>
<gl-drawer :z-index="1400" :open="showDrawer" @close="closeDrawer">
<template #title>
<h2 class="gl-my-0 gl-text-size-h2 gl-leading-24">
{{ s__('ContainerRegistry|Add protection rule') }}
</h2>
</template>
<template #default>
<container-protection-tag-rule-form />
</template>
</gl-drawer>
<gl-modal
v-if="protectionRuleMutationItem"
:modal-id="$options.modal.id"

View File

@ -200,21 +200,6 @@ export default {
return !this.isUsingLfs || (this.isUsingLfs && this.lfsWarningDismissed);
},
},
watch: {
createNewBranch: {
handler(newValue) {
if (newValue) {
this.form.fields.branch_name.value = '';
} else {
this.form.fields.branch_name = {
...this.form.fields.branch_name,
value: this.originalBranch,
state: true,
};
}
},
},
},
methods: {
show() {
this.$refs[this.modalId].show();
@ -233,9 +218,7 @@ export default {
e.preventDefault(); // Prevent modal from closing
if (this.showLfsWarning) {
this.lfsWarningDismissed = true;
await this.$nextTick();
this.$refs.message?.$el.focus();
await this.handleContinueLfsWarning();
return;
}

View File

@ -65,7 +65,7 @@ export default {
formData.append('dir_name', this.dir);
if (!formData.has('branch_name')) {
formData.append('branch_name', this.targetBranch);
formData.append('branch_name', this.originalBranch);
}
return axios

View File

@ -118,7 +118,7 @@ export default {
:href="targetUrl"
:data-track-label="trackingLabel"
:data-track-action="$options.TRACK_ACTION"
class="gl-flex gl-min-w-0 gl-flex-1 gl-flex-wrap gl-justify-end gl-gap-y-3 !gl-text-default !gl-no-underline !gl-outline-none sm:gl-flex-nowrap sm:gl-items-center"
class="gl-flex gl-min-w-0 gl-flex-1 gl-flex-wrap gl-justify-end gl-gap-y-3 !gl-text-default !gl-no-underline sm:gl-flex-nowrap sm:gl-items-center"
>
<div
class="gl-w-64 gl-flex-grow-2 gl-self-center gl-overflow-hidden gl-overflow-x-auto sm:gl-w-auto"

View File

@ -174,7 +174,7 @@ export default {
},
},
i18n: {
snooze: s__('Todos|Snooze'),
snooze: s__('Todos|Snooze...'),
snoozeError: s__('Todos|Failed to snooze todo. Try again later.'),
unSnooze: s__('Todos|Remove snooze'),
unSnoozeError: s__('Todos|Failed to un-snooze todo. Try again later.'),

View File

@ -0,0 +1,34 @@
import { GlBadge, GlIcon } from '@gitlab/ui';
import MultipleChoiceSelector from './multiple_choice_selector.vue';
import MultipleChoiceSelectorItem from './multiple_choice_selector_item.vue';
export default {
component: MultipleChoiceSelector,
title: 'vue_shared/multiple_choice_selector',
};
const data = () => ({
selected: ['option', 'option-two'],
});
const Template = () => ({
components: { MultipleChoiceSelector, MultipleChoiceSelectorItem, GlBadge, GlIcon },
data,
template: `<multiple-choice-selector :selected="selected">
<multiple-choice-selector-item value="option" title="Option name" description="This is a description for this option. Descriptions are optional." :disabled="false"></multiple-choice-selector-item>
<multiple-choice-selector-item value="option-two" title="Option name" description="This is a description for this option. Descriptions are optional." :disabled="false"></multiple-choice-selector-item>
<multiple-choice-selector-item value="option-3" description="This is a description for this option. Descriptions are optional." :disabled="false">
Option name
<gl-badge variant="muted">Beta</gl-badge>
<div class="gl-flex gl-gap-2">
<gl-icon name="tanuki" />
<gl-icon name="github" />
<gl-icon name="bitbucket" />
<gl-icon name="gitea" />
</div>
</multiple-choice-selector-item>
<multiple-choice-selector-item value="option-4" title="Option name" description="This is a description for this option. Descriptions are optional." :disabled="true" disabledMessage="This option is only available in other cases"></multiple-choice-selector-item>
</multiple-choice-selector>`,
});
export const Default = Template.bind({});

View File

@ -0,0 +1,27 @@
<script>
import { GlFormCheckboxGroup } from '@gitlab/ui';
export default {
components: { GlFormCheckboxGroup },
props: {
selected: {
type: Array,
required: true,
},
},
data() {
return {
selectedOptions: this.selected,
};
},
};
</script>
<template>
<gl-form-checkbox-group
v-model="selectedOptions"
class="multiple-choice-selector gl-border gl-block gl-rounded-base"
>
<slot></slot>
</gl-form-checkbox-group>
</template>

View File

@ -0,0 +1,55 @@
<script>
import { GlFormCheckbox } from '@gitlab/ui';
export default {
components: { GlFormCheckbox },
props: {
value: {
type: String,
required: false,
default: null,
},
title: {
type: String,
required: false,
default: null,
},
description: {
type: String,
required: false,
default: null,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
disabledMessage: {
type: String,
required: false,
default: null,
},
},
};
</script>
<template>
<div class="multiple-choice-selector-item gl-p-5">
<gl-form-checkbox :value="value" :disabled="disabled">
<div class="multiple-choice-selector-click-area"></div>
<div
class="multiple-choice-selector-item-title gl-flex gl-flex-wrap gl-items-center gl-gap-3 gl-font-bold"
>
<slot>
{{ title }}
</slot>
</div>
<p v-if="disabled && disabledMessage" class="help-text !gl-text-warning">
{{ disabledMessage }}
</p>
<p v-if="description" class="help-text">
{{ description }}
</p>
</gl-form-checkbox>
</div>
</template>

View File

@ -0,0 +1,34 @@
import { GlBadge, GlIcon } from '@gitlab/ui';
import SingleChoiceSelector from './single_choice_selector.vue';
import SingleChoiceSelectorItem from './single_choice_selector_item.vue';
export default {
component: SingleChoiceSelector,
title: 'vue_shared/single_choice_selector',
};
const data = () => ({
checked: 'option',
});
const Template = () => ({
components: { SingleChoiceSelector, SingleChoiceSelectorItem, GlBadge, GlIcon },
data,
template: `<single-choice-selector :checked="checked">
<single-choice-selector-item value="option" title="Option name" description="This is a description for this option. Descriptions are optional." :disabled="false"></single-choice-selector-item>
<single-choice-selector-item value="option-two" title="Option name" description="This is a description for this option. Descriptions are optional." :disabled="false"></single-choice-selector-item>
<single-choice-selector-item value="option-3" description="This is a description for this option. Descriptions are optional." :disabled="false">
Option name
<gl-badge variant="muted">Beta</gl-badge>
<div class="gl-flex gl-gap-2">
<gl-icon name="tanuki" />
<gl-icon name="github" />
<gl-icon name="bitbucket" />
<gl-icon name="gitea" />
</div>
</single-choice-selector-item>
<single-choice-selector-item value="option-4" title="Option name" description="This is a description for this option. Descriptions are optional." :disabled="true" disabledMessage="This option is only available in other cases"></single-choice-selector-item>
</single-choice-selector>`,
});
export const Default = Template.bind({});

View File

@ -0,0 +1,27 @@
<script>
import { GlFormRadioGroup } from '@gitlab/ui';
export default {
components: { GlFormRadioGroup },
props: {
checked: {
type: String,
required: true,
},
},
data() {
return {
checkedOptions: this.checked,
};
},
};
</script>
<template>
<gl-form-radio-group
v-model="checkedOptions"
class="multiple-choice-selector gl-border gl-block gl-rounded-base"
>
<slot></slot>
</gl-form-radio-group>
</template>

View File

@ -0,0 +1,53 @@
<script>
import { GlFormRadio } from '@gitlab/ui';
export default {
components: { GlFormRadio },
props: {
value: {
type: String,
required: false,
default: null,
},
title: {
type: String,
required: false,
default: null,
},
description: {
type: String,
required: false,
default: null,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
disabledMessage: {
type: String,
required: false,
default: null,
},
},
};
</script>
<template>
<div class="multiple-choice-selector-item gl-p-5">
<gl-form-radio :value="value" :disabled="disabled">
<div class="multiple-choice-selector-click-area"></div>
<div class="gl-flex gl-flex-wrap gl-items-center gl-gap-3 gl-font-bold">
<slot>
{{ title }}
</slot>
</div>
<p v-if="disabled && disabledMessage" class="help-text !gl-text-warning">
{{ disabledMessage }}
</p>
<p v-if="description" class="help-text">
{{ description }}
</p>
</gl-form-radio>
</div>
</template>

View File

@ -3,6 +3,7 @@
@import './content_editor';
@import './deployment_instance';
@import './detail_page';
@import './multiple_choice_selector';
@import './ref_selector';
@import './related_items_list';
@import './severity/icons';

View File

@ -0,0 +1,51 @@
.multiple-choice-selector {
&-item {
@include gl-prefers-reduced-motion-transition;
transition: background-color $gl-transition-duration-medium $gl-easing-out-cubic,
border-color $gl-transition-duration-medium $gl-easing-out-cubic;
&:not(:last-child) {
@apply gl-border-b;
}
&:first-child {
@apply gl-rounded-t-base;
}
&:last-child {
@apply gl-rounded-b-base;
}
// stylelint-disable-next-line gitlab/no-gl-class
&.multiple-choice-selector-item .gl-form-checkbox.gl-form-checkbox label,
&.multiple-choice-selector-item .gl-form-radio.gl-form-radio label {
width: 100%;
margin-bottom: 0;
}
&:has(input:checked) {
border: 1px solid var(--gl-control-border-color-selected-default);
@apply gl-bg-subtle gl-rounded-base;
}
&:has(input:checked) + &:has(input:checked) {
@apply gl-rounded-t-none;
}
&:has(input:checked):has(+ & input:checked) {
@apply gl-rounded-b-none;
}
&:not(:last-child):has(input:checked) {
margin: -1px -1px 0;
}
&:last-child:has(input:checked) {
margin: -1px;
}
}
&-click-area {
@apply gl-absolute -gl-top-5 -gl-left-7 gl-w-full gl-h-full gl-p-5 gl-pl-7 gl-box-content -gl-z-1;
}
}

View File

@ -7,6 +7,19 @@ module IconsHelper
DEFAULT_ICON_SIZE = 16
VARIANT_CLASSES = {
current: 'gl-fill-current',
default: 'gl-fill-icon-default',
subtle: 'gl-fill-icon-subtle',
strong: 'gl-fill-icon-strong',
disabled: 'gl-fill-icon-disabled',
link: 'gl-fill-icon-link',
info: 'gl-fill-icon-info',
warning: 'gl-fill-icon-warning',
danger: 'gl-fill-icon-danger',
success: 'gl-fill-icon-success'
}.freeze
def custom_icon(icon_name, size: DEFAULT_ICON_SIZE)
memoized_icon("#{icon_name}_#{size}") do
render partial: "shared/icons/#{icon_name}", formats: :svg, locals: { size: size }
@ -29,8 +42,8 @@ module IconsHelper
ActionController::Base.helpers.image_path('file_icons/file_icons.svg', host: sprite_base_url)
end
def sprite_icon(icon_name, size: DEFAULT_ICON_SIZE, css_class: nil, file_icon: false, aria_label: nil)
memoized_icon("#{icon_name}_#{size}_#{css_class}") do
def sprite_icon(icon_name, size: DEFAULT_ICON_SIZE, css_class: nil, file_icon: false, aria_label: nil, variant: nil)
memoized_icon("#{icon_name}_#{size}_#{css_class}_#{variant}") do
unknown_icon = file_icon ? unknown_file_icon_sprite(icon_name) : unknown_icon_sprite(icon_name)
if unknown_icon
exception = ArgumentError.new("#{icon_name} is not a known icon in @gitlab-org/gitlab-svg")
@ -39,7 +52,10 @@ module IconsHelper
css_classes = []
css_classes << "s#{size}" if size
css_classes << VARIANT_CLASSES[variant&.to_sym]
css_classes << css_class.to_s unless css_class.blank?
css_classes.compact!
sprite_path = file_icon ? sprite_file_icons_path : sprite_icon_path
content_tag(

View File

@ -34,11 +34,7 @@ module TreeHelper
def tree_edit_branch(project = @project, ref = @ref)
return unless can_edit_tree?(project, ref)
if user_access(project).can_push_to_branch?(ref)
ref
else
patch_branch_name(ref)
end
patch_branch_name(ref)
end
# Generate a patch branch name that should look like:

View File

@ -12,7 +12,7 @@
note_id: note.id } }
.timeline-entry-inner
- if note.system
.gl-float-left.gl-flex.gl-justify-center.gl-items-center.gl-rounded-full.-gl-mt-1.gl-ml-2.gl-w-6.gl-h-6.gl-bg-gray-50.gl-text-subtle
.gl-float-left.gl-flex.gl-justify-center.gl-items-center.gl-rounded-full.-gl-mt-1.gl-ml-2.gl-w-6.gl-h-6.gl-bg-strong.gl-text-subtle
= icon_for_system_note(note)
- else
.timeline-avatar.gl-float-left

View File

@ -7,6 +7,5 @@ Premailer::Rails.config.merge!(
remove_comments: true,
remove_ids: false,
remove_scripts: false,
output_encoding: 'US-ASCII',
strategies: ::Rails.env.production? ? [:asset_pipeline] : [:asset_pipeline, :network]
)

View File

@ -5,4 +5,4 @@ feature_category: runner
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/172422
milestone: '17.8'
queued_migration_version: 20241230163745
finalized_by: # version of the migration that finalized this BBM
finalized_by: 20250113153424

View File

@ -4,7 +4,7 @@ description: >-
Removes ci_runner_machines_687967fa8a records that don't have a matching ci_runners_e59bb2812d record.
This can happen because there was a period in time where a FK didn't exist.
feature_category: fleet_visibility
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/172422
milestone: '17.8'
queued_migration_version: 20241231094025
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/176702
milestone: '17.9'
queued_migration_version: 20250113164152
finalized_by: # version of the migration that finalized this BBM

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class FinalizeDeleteOrphanedCiRunnerProjects < Gitlab::Database::Migration[2.2]
milestone '17.9'
disable_ddl_transaction!
restrict_gitlab_migration gitlab_schema: :gitlab_ci
def up
ensure_batched_background_migration_is_finished(
job_class_name: 'DeleteOrphanedCiRunnerProjects',
table_name: :ci_runner_projects,
column_name: :runner_id,
job_arguments: [],
finalize: true
)
end
def down; end
end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
class ValidateForeignKeyForRunnerIdInCiRunnerProjects < Gitlab::Database::Migration[2.2]
milestone '17.9'
def up
validate_foreign_key(:ci_runner_projects, :runner_id)
end
def down
# Can be safely a no-op if we don't roll back the inconsistent data.
end
end

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
class RequeueDeleteOrphanedPartitionedCiRunnerMachineRecords < Gitlab::Database::Migration[2.2]
milestone '17.9'
restrict_gitlab_migration gitlab_schema: :gitlab_ci
MIGRATION = "DeleteOrphanedPartitionedCiRunnerMachineRecords"
DELAY_INTERVAL = 2.minutes
BATCH_SIZE = 1000
SUB_BATCH_SIZE = 100
def up
delete_batched_background_migration(MIGRATION, :ci_runner_machines_687967fa8a, :runner_id, [])
queue_batched_background_migration(
MIGRATION,
:ci_runner_machines_687967fa8a,
:runner_id,
job_interval: DELAY_INTERVAL,
batch_size: BATCH_SIZE,
batch_class_name: 'LooseIndexScanBatchingStrategy',
sub_batch_size: SUB_BATCH_SIZE
)
end
def down
delete_batched_background_migration(MIGRATION, :ci_runner_machines_687967fa8a, :runner_id, [])
end
end

View File

@ -0,0 +1 @@
1a31c384457bee054e5b2b0c3d9029282075c5a00699d8a1ba995b3fda461e72

View File

@ -0,0 +1 @@
e1e13c73f5443941b257172c9fda0cc463928685d490f58baaf4a3f5e0ed9d24

View File

@ -0,0 +1 @@
6cc3cc278a9b65bd2b1aa56e6ea9b70ec194083090d6b60d5f0b8b6c3641f4d2

View File

@ -37392,7 +37392,7 @@ ALTER TABLE ONLY approval_project_rules_users
ADD CONSTRAINT fk_0dfcd9e339 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY ci_runner_projects
ADD CONSTRAINT fk_0e743433ff FOREIGN KEY (runner_id) REFERENCES ci_runners(id) ON DELETE CASCADE NOT VALID;
ADD CONSTRAINT fk_0e743433ff FOREIGN KEY (runner_id) REFERENCES ci_runners(id) ON DELETE CASCADE;
ALTER TABLE ONLY security_policy_project_links
ADD CONSTRAINT fk_0eba4d5d71 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;

View File

@ -30,9 +30,13 @@ To connect to an external repository:
1. Select **GitHub** or **Repository by URL**.
1. Complete the fields.
If the **Run CI/CD for external repository** option is not available, the GitLab instance
might not have any import sources configured. Ask an administrator for your instance to check
the [import sources configuration](../../administration/settings/import_and_export_settings.md#configure-allowed-import-sources).
If the **Run CI/CD for external repository** option is not available:
- The GitLab instance might not have any import sources configured.
Ask an administrator to check the [import sources configuration](../../administration/settings/import_and_export_settings.md#configure-allowed-import-sources).
- [Project mirroring](../../user/project/repository/mirror/index.md) might be disabled.
If disabled, only administrators can use the **Run CI/CD for external repository** option.
Ask an administrator to check the [project mirroring configuration](../../administration/settings/visibility_and_access_controls.md#enable-project-mirroring).
## Pipelines for external pull requests

View File

@ -89,6 +89,11 @@ For more information about upgrading GitLab Helm Chart, see [the release notes f
the upgrade. This bug has been fixed with GitLab 17.1.2 and upgrading from GitLab 16.x directly to 17.1.2 will not
cause these issues.
- A [known issue](https://gitlab.com/gitlab-org/gitlab/-/issues/476542) in the Git versions shipped with
GitLab 17.0.x and GitLab 17.1.x causes a noticeable increase in CPU usage when under load. The primary cause of
this regression was resolved in the Git versions shipped with GitLab 17.2 so, for systems that see heavy peak loads,
you should upgrade to GitLab 17.2.
### Linux package installations
Specific information applies to Linux package installations:

View File

@ -245,7 +245,7 @@ If you encounter issues:
1. Ensure that the project you want to use it with meets the [prerequisites](#prerequisites).
1. Ensure that the folder you opened in VS Code has a Git repository for your GitLab project.
1. Ensure that you've checked out the branch for the code you'd like to change.
1. Check your Docker and Docker socket configuration:
1. Check your Docker configuration:
1. [Install Docker and set the socket file path](#install-docker-and-set-the-socket-file-path).
1. Restart your container manager. For example, if you use Colima, `colima restart`.
1. Pull the base Docker image:
@ -254,15 +254,8 @@ If you encounter issues:
docker pull registry.gitlab.com/gitlab-org/duo-workflow/default-docker-image/workflow-generic-image:v0.0.4
```
1. If this does not work the DNS configuration of Colima might be at fault. Edit the DNS setting in `~/.colima/default/colima.yaml` to `dns: [1.1.1.1]` and then restart Colima with `colima restart`.
1. Check the Language Server logs:
1. To open the logs in VS Code, select **View** > **Output**. In the output panel at the bottom, in the top-right corner, select **GitLab Workflow** or **GitLab Language Server** from the list.
1. Review for errors, warnings, connection issues, or authentication problems.
1. For more output in the logs, open the settings:
- On macOS: <kbd>Cmd</kbd> + <kbd>,</kbd>
1. For permission issues, ensure your operating system user has the necessary Docker permissions.
1. Verify Docker's internet connectivity by executing the command `docker image pull redhat/ubi8`.
If this does not work, the DNS configuration of Colima might be at fault.
Edit the DNS setting in `~/.colima/default/colima.yaml` to `dns: [1.1.1.1]` and then restart Colima with `colima restart`.
1. Check the Language Server logs:

View File

@ -1010,9 +1010,6 @@ msgstr ""
msgid "%{labelStart}Project:%{labelEnd} %{project}"
msgstr ""
msgid "%{labelStart}Report Type:%{labelEnd} %{reportType}"
msgstr ""
msgid "%{labelStart}Scanner:%{labelEnd} %{scanner}"
msgstr ""
@ -1022,6 +1019,9 @@ msgstr ""
msgid "%{labelStart}Severity:%{labelEnd} %{severity}"
msgstr ""
msgid "%{labelStart}Tool:%{labelEnd} %{reportType}"
msgstr ""
msgid "%{labelStart}URL:%{labelEnd} %{url}"
msgstr ""
@ -2413,7 +2413,7 @@ msgstr ""
msgid "AI|Write a summary to fill out the selected issue template"
msgstr ""
msgid "AI|Your request does not seem to contain code to %{action}. To %{human_name} select the lines of code in your editor and then type the command %{command_name} in the chat. You may add additional instructions after this command. If you have no code to select, you can also simply add the code after the command."
msgid "AI|Your request does not seem to contain code to %{action}. To %{human_name} select the lines of code in your %{platform} and then type the command %{command_name} in the chat. You may add additional instructions after this command. If you have no code to select, you can also simply add the code after the command."
msgstr ""
msgid "API"
@ -15603,6 +15603,12 @@ msgstr ""
msgid "ContainerRegistry|Minimum access level to push"
msgstr ""
msgid "ContainerRegistry|Minimum role allowed to delete"
msgstr ""
msgid "ContainerRegistry|Minimum role allowed to push"
msgstr ""
msgid "ContainerRegistry|Missing or insufficient permission, delete button disabled"
msgstr ""
@ -15621,6 +15627,12 @@ msgstr ""
msgid "ContainerRegistry|Note: Any policy update will result in a change to the scheduled run date and time"
msgstr ""
msgid "ContainerRegistry|Only users with at least this role can delete tags with a name that matches the protection rule."
msgstr ""
msgid "ContainerRegistry|Only users with at least this role can push tags with a name that matches the protection rule."
msgstr ""
msgid "ContainerRegistry|Partial cleanup complete"
msgstr ""
@ -15630,6 +15642,9 @@ msgstr ""
msgid "ContainerRegistry|Please try different search criteria"
msgstr ""
msgid "ContainerRegistry|Protect container tags matching"
msgstr ""
msgid "ContainerRegistry|Protected container image tags"
msgstr ""
@ -15749,6 +15764,9 @@ msgstr ""
msgid "ContainerRegistry|Tags with names that match this regex pattern are kept. %{linkStart}View regex examples.%{linkEnd}"
msgstr ""
msgid "ContainerRegistry|Tags with names that match this regex pattern are protected. Must be less than 100 characters. %{linkStart}What regex patterns are supported?%{linkEnd}"
msgstr ""
msgid "ContainerRegistry|Tags with names that match this regex pattern are removed. %{linkStart}View regex examples.%{linkEnd}"
msgstr ""
@ -47464,9 +47482,6 @@ msgstr ""
msgid "Reports|New"
msgstr ""
msgid "Reports|Report Type"
msgstr ""
msgid "Reports|See test results while the pipeline is running"
msgstr ""
@ -47479,6 +47494,9 @@ msgstr ""
msgid "Reports|Test summary results are being parsed"
msgstr ""
msgid "Reports|Tool"
msgstr ""
msgid "Reports|View partial report"
msgstr ""
@ -59295,6 +59313,9 @@ msgstr ""
msgid "Todos|Snooze"
msgstr ""
msgid "Todos|Snooze..."
msgstr ""
msgid "Todos|Snoozed"
msgstr ""

View File

@ -243,6 +243,23 @@ describe('CI Variable Drawer', () => {
expect(findVisibilityRadioGroup().attributes('checked')).toBe(expectedVisibility);
},
);
it('is updated on variable update', async () => {
await createComponent({
props: {
selectedVariable: {
...mockProjectVariableFileType,
masked: true,
hidden: true,
},
},
});
expect(findVisibilityRadioGroup().attributes('checked')).toBe(VISIBILITY_HIDDEN);
await wrapper.setProps({ mutationResponse: { message: 'Success', hasError: false } });
expect(findVisibilityRadioGroup().attributes('checked')).toBe(VISIBILITY_VISIBLE);
});
});
it('is disabled when editing a hidden variable', () => {

View File

@ -4,6 +4,7 @@ import imageDiscussionFixture from 'test_fixtures/merge_requests/image_diff_disc
import { createStore } from '~/mr_notes/stores';
import DiffWithNote from '~/notes/components/diff_with_note.vue';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
describe('diff_with_note', () => {
let store;
@ -22,6 +23,7 @@ describe('diff_with_note', () => {
};
const findDiffViewer = () => wrapper.findComponent(DiffViewer);
const findDiffFileHeader = () => wrapper.findComponent(DiffFileHeader);
beforeEach(() => {
store = createStore();
@ -76,16 +78,76 @@ describe('diff_with_note', () => {
});
describe('image diff', () => {
beforeEach(() => {
const imageDiscussion = imageDiscussionFixture[0];
wrapper = shallowMount(DiffWithNote, {
propsData: { discussion: imageDiscussion, diffFile: {} },
store,
describe('when discussion has a diff_file', () => {
beforeEach(() => {
const imageDiscussion = imageDiscussionFixture[0];
wrapper = shallowMount(DiffWithNote, {
propsData: { discussion: imageDiscussion, diffFile: {} },
store,
});
});
it('shows image diff', () => {
expect(selectors.diffTable.exists()).toBe(false);
expect(findDiffViewer().exists()).toBe(true);
expect(findDiffFileHeader().exists()).toBe(true);
});
});
it('shows image diff', () => {
expect(selectors.diffTable.exists()).toBe(false);
describe('when discussion does not have a diff_file', () => {
beforeEach(() => {
const imageDiscussion = JSON.parse(JSON.stringify(imageDiscussionFixture[0]));
delete imageDiscussion.diff_file;
wrapper = shallowMount(DiffWithNote, {
propsData: { discussion: imageDiscussion, diffFile: {} },
store,
});
});
it('does not show image diff', () => {
expect(findDiffViewer().exists()).toBe(false);
expect(selectors.diffTable.exists()).toBe(false);
expect(findDiffFileHeader().exists()).toBe(true);
});
});
});
describe('file diff', () => {
describe('when discussion has a diff_file', () => {
beforeEach(() => {
const fileDiscussion = JSON.parse(JSON.stringify(discussionFixture[0]));
fileDiscussion.position.position_type = 'file';
fileDiscussion.original_position.position_type = 'file';
wrapper = shallowMount(DiffWithNote, {
propsData: { discussion: fileDiscussion, diffFile: {} },
store,
});
});
it('shows file header', () => {
expect(findDiffFileHeader().exists()).toBe(true);
});
});
describe('when discussion does not have a diff_file', () => {
beforeEach(() => {
const fileDiscussion = JSON.parse(JSON.stringify(discussionFixture[0]));
delete fileDiscussion.diff_file;
fileDiscussion.position.position_type = 'file';
fileDiscussion.original_position.position_type = 'file';
wrapper = shallowMount(DiffWithNote, {
propsData: { discussion: fileDiscussion, diffFile: {} },
store,
});
});
it('shows file header', () => {
expect(findDiffFileHeader().exists()).toBe(true);
});
});
});

View File

@ -87,6 +87,25 @@ describe('noteable_discussion component', () => {
expect(wrapper.find('.discussion-header').exists()).toBe(true);
});
describe('when diff discussion does not have a diff_file', () => {
it.each`
positionType
${'file'}
${'image'}
`('should show reply actions when position_type is $positionType', async ({ positionType }) => {
const discussion = { ...discussionMock, original_position: { position_type: positionType } };
discussion.diff_file = { ...getDiffFileMock(), diff_refs: null };
discussion.diff_discussion = true;
wrapper.setProps({ discussion });
await nextTick();
const replyWrapper = wrapper.find('[data-testid="reply-wrapper"]');
expect(replyWrapper.exists()).toBe(true);
});
});
describe('drafts', () => {
useLocalStorageSpy();

View File

@ -0,0 +1,68 @@
import { mountExtended } from 'helpers/vue_test_utils_helper';
import ContainerProtectionTagRuleForm from '~/packages_and_registries/settings/project/components/container_protection_tag_rule_form.vue';
describe('container Protection Rule Form', () => {
let wrapper;
const defaultProvidedValues = {
projectPath: 'path',
};
const findTagNamePatternInput = () =>
wrapper.findByRole('textbox', { name: /protect container tags matching/i });
const findMinimumAccessLevelForPushSelect = () =>
wrapper.findByRole('combobox', { name: /minimum role allowed to push/i });
const findMinimumAccessLevelForDeleteSelect = () =>
wrapper.findByRole('combobox', { name: /minimum role allowed to delete/i });
const mountComponent = ({ config, provide = defaultProvidedValues } = {}) => {
wrapper = mountExtended(ContainerProtectionTagRuleForm, {
provide,
...config,
});
};
describe('form fields', () => {
describe('form field "tagNamePattern"', () => {
it('exists', () => {
mountComponent();
expect(findTagNamePatternInput().exists()).toBe(true);
});
});
describe('form field "minimumAccessLevelForPush"', () => {
const minimumAccessLevelForPushOptions = () =>
findMinimumAccessLevelForPushSelect()
.findAll('option')
.wrappers.map((option) => option.element.value);
it.each(['MAINTAINER', 'OWNER', 'ADMIN'])(
'includes the access level "%s" as an option',
(accessLevel) => {
mountComponent();
expect(findMinimumAccessLevelForPushSelect().exists()).toBe(true);
expect(minimumAccessLevelForPushOptions()).toContain(accessLevel);
},
);
});
describe('form field "minimumAccessLevelForDelete"', () => {
const minimumAccessLevelForDeleteOptions = () =>
findMinimumAccessLevelForDeleteSelect()
.findAll('option')
.wrappers.map((option) => option.element.value);
it.each(['MAINTAINER', 'OWNER', 'ADMIN'])(
'includes the access level "%s" as an option',
(accessLevel) => {
mountComponent();
expect(findMinimumAccessLevelForDeleteSelect().exists()).toBe(true);
expect(minimumAccessLevelForDeleteOptions()).toContain(accessLevel);
},
);
});
});
});

View File

@ -1,6 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { GlAlert, GlBadge, GlModal, GlSprintf, GlTable } from '@gitlab/ui';
import { GlAlert, GlBadge, GlDrawer, GlModal, GlSprintf, GlTable } from '@gitlab/ui';
import containerProtectionTagRuleEmptyRulesQueryPayload from 'test_fixtures/graphql/packages_and_registries/settings/project/graphql/queries/get_container_protection_tag_rules.query.graphql.empty_rules.json';
import containerProtectionTagRuleNullProjectQueryPayload from 'test_fixtures/graphql/packages_and_registries/settings/project/graphql/queries/get_container_protection_tag_rules.query.graphql.null_project.json';
@ -18,6 +18,7 @@ import {
import waitForPromises from 'helpers/wait_for_promises';
import CrudComponent from '~/vue_shared/components/crud_component.vue';
import ContainerProtectionTagRules from '~/packages_and_registries/settings/project/components/container_protection_tag_rules.vue';
import ContainerProtectionTagRuleForm from '~/packages_and_registries/settings/project/components/container_protection_tag_rule_form.vue';
import getContainerProtectionTagRulesQuery from '~/packages_and_registries/settings/project/graphql/queries/get_container_protection_tag_rules.query.graphql';
import deleteContainerProtectionTagRuleMutation from '~/packages_and_registries/settings/project/graphql/mutations/delete_container_protection_tag_rule.mutation.graphql';
import { MinimumAccessLevelOptions } from '~/packages_and_registries/settings/project/constants';
@ -36,6 +37,9 @@ describe('ContainerProtectionTagRules', () => {
const findTableComponent = () => wrapper.findComponent(GlTable);
const findBadge = () => wrapper.findComponent(GlBadge);
const findAlert = () => wrapper.findComponent(GlAlert);
const findDrawer = () => wrapper.findComponent(GlDrawer);
const findDrawerTitle = () => wrapper.findComponent(GlDrawer).find('h2');
const findForm = () => wrapper.findComponent(ContainerProtectionTagRuleForm);
const findModal = () => wrapper.findComponent(GlModal);
const defaultProvidedValues = {
@ -83,6 +87,7 @@ describe('ContainerProtectionTagRules', () => {
it('renders card component with title', () => {
expect(findCrudComponent().props('title')).toBe('Protected container image tags');
expect(findCrudComponent().props('toggleText')).toBe('Add protection rule');
});
it('renders card component with description', () => {
@ -95,6 +100,10 @@ describe('ContainerProtectionTagRules', () => {
expect(findLoader().exists()).toBe(true);
});
it('drawer is hidden', () => {
expect(findDrawer().props('open')).toBe(false);
});
it('hides the table', () => {
expect(findTableComponent().exists()).toBe(false);
});
@ -113,6 +122,31 @@ 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 data is loaded & contains tag protection rules', () => {

View File

@ -235,26 +235,6 @@ describe('CommitChangesModal', () => {
});
});
it('clear branch name when new branch option is selected', async () => {
createComponent();
expect(wrapper.vm.$data.form.fields.branch_name).toEqual({
feedback: null,
required: true,
state: true,
value: 'some-target-branch',
});
findFormRadioGroup().vm.$emit('input', true);
await nextTick();
expect(wrapper.vm.$data.form.fields.branch_name).toEqual({
feedback: null,
required: true,
state: true,
value: '',
});
});
it.each`
input | value | emptyRepo | canPushCode | canPushToBranch | exist
${'authenticity_token'} | ${'mock-csrf-token'} | ${false} | ${true} | ${true} | ${true}

View File

@ -27,7 +27,7 @@ const initialProps = {
const defaultFormValue = {
dirName: 'foo',
originalBranch: initialProps.originalBranch,
branchName: initialProps.targetBranch,
targetBranch: initialProps.targetBranch,
commitMessage: initialProps.commitMessage,
createNewMr: true,
};
@ -60,8 +60,12 @@ describe('NewDirectoryModal', () => {
await nextTick();
};
const submitForm = async () => {
findCommitChangesModal().vm.$emit('submit-form', new FormData());
const submitForm = async ({ branchName } = {}) => {
const formData = new FormData();
if (branchName) {
formData.append('branch_name', branchName);
}
findCommitChangesModal().vm.$emit('submit-form', formData);
await waitForPromises();
};
@ -108,15 +112,28 @@ describe('NewDirectoryModal', () => {
expect(findCommitChangesModal().props('valid')).toBe(true);
});
it('passes additional formData', async () => {
const { dirName, branchName } = defaultFormValue;
mock.onPost(initialProps.path).reply(HTTP_STATUS_OK, {});
await fillForm();
await submitForm();
describe('passes additional formData', () => {
it('passes original branch name as branch name if branch name does not exist on formData', async () => {
const { dirName, originalBranch } = defaultFormValue;
mock.onPost(initialProps.path).reply(HTTP_STATUS_OK, {});
await fillForm();
await submitForm();
const formData = mock.history.post[0].data;
expect(formData.get('dir_name')).toBe(dirName);
expect(formData.get('branch_name')).toBe(branchName);
const formData = mock.history.post[0].data;
expect(formData.get('dir_name')).toBe(dirName);
expect(formData.get('branch_name')).toBe(originalBranch);
});
it('passes target branch name as branch name if branch name does exist on formData', async () => {
const { dirName, targetBranch } = defaultFormValue;
mock.onPost(initialProps.path).reply(HTTP_STATUS_OK, {});
await fillForm();
await submitForm({ branchName: targetBranch });
const formData = mock.history.post[0].data;
expect(formData.get('dir_name')).toBe(dirName);
expect(formData.get('branch_name')).toBe(targetBranch);
});
});
it('redirects to the new directory', async () => {

View File

@ -131,7 +131,7 @@ describe('ToggleSnoozedStatus', () => {
createComponent({ props: { isSnoozed: false, isPending: true } });
expect(findSnoozeDropdown().props()).toMatchObject({
toggleText: 'Snooze',
toggleText: 'Snooze...',
icon: 'clock',
placement: 'bottom-end',
textSrOnly: true,
@ -202,7 +202,7 @@ describe('ToggleSnoozedStatus', () => {
const tooltip = findGlTooltip();
expect(tooltip.exists()).toBe(true);
expect(tooltip.text()).toBe('Snooze');
expect(tooltip.text()).toBe('Snooze...');
});
it('only shows the tooltip when the dropdown is closed', async () => {

View File

@ -0,0 +1,41 @@
import { GlFormCheckbox } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import MultipleChoiceSelectorItem from '~/vue_shared/components/multiple_choice_selector_item.vue';
describe('MultipleChoiceSelectorItem', () => {
let wrapper;
function createComponent({ propsData = {} } = {}) {
wrapper = shallowMount(MultipleChoiceSelectorItem, {
propsData: {
...propsData,
},
});
}
const findCheckbox = () => wrapper.findComponent(GlFormCheckbox);
it('renders checkbox', () => {
createComponent();
expect(findCheckbox().exists()).toBe(true);
});
it('renders title', () => {
createComponent({ propsData: { title: 'Option title' } });
expect(findCheckbox().text()).toContain('Option title');
});
it('renders description', () => {
createComponent({ propsData: { description: 'Option description' } });
expect(wrapper.text()).toContain('Option description');
});
it('renders disabled message', () => {
createComponent({ propsData: { disabledMessage: 'Option disabled message', disabled: true } });
expect(wrapper.text()).toContain('Option disabled message');
});
});

View File

@ -0,0 +1,28 @@
import { GlFormCheckboxGroup } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import MultipleChoiceSelector from '~/vue_shared/components/multiple_choice_selector.vue';
describe('MultipleChoiceSelector', () => {
let wrapper;
const defaultPropsData = {
selected: ['option'],
};
function createComponent({ propsData = {} } = {}) {
wrapper = shallowMount(MultipleChoiceSelector, {
propsData: {
...defaultPropsData,
...propsData,
},
});
}
const findCheckboxGroup = () => wrapper.findComponent(GlFormCheckboxGroup);
it('renders checkbox group', () => {
createComponent();
expect(findCheckboxGroup().exists()).toBe(true);
});
});

View File

@ -0,0 +1,41 @@
import { GlFormRadio } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import SingleChoiceSelectorItem from '~/vue_shared/components/single_choice_selector_item.vue';
describe('SingleChoiceSelectorItem', () => {
let wrapper;
function createComponent({ propsData = {} } = {}) {
wrapper = shallowMount(SingleChoiceSelectorItem, {
propsData: {
...propsData,
},
});
}
const findRadio = () => wrapper.findComponent(GlFormRadio);
it('renders radio', () => {
createComponent();
expect(findRadio().exists()).toBe(true);
});
it('renders title', () => {
createComponent({ propsData: { title: 'Option title' } });
expect(findRadio().text()).toContain('Option title');
});
it('renders description', () => {
createComponent({ propsData: { description: 'Option description' } });
expect(wrapper.text()).toContain('Option description');
});
it('renders disabled message', () => {
createComponent({ propsData: { disabledMessage: 'Option disabled message', disabled: true } });
expect(wrapper.text()).toContain('Option disabled message');
});
});

View File

@ -0,0 +1,28 @@
import { GlFormRadioGroup } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import SingleChoiceSelector from '~/vue_shared/components/single_choice_selector.vue';
describe('SingleChoice', () => {
let wrapper;
const defaultPropsData = {
checked: 'option',
};
function createComponent({ propsData = {} } = {}) {
wrapper = shallowMount(SingleChoiceSelector, {
propsData: {
...defaultPropsData,
...propsData,
},
});
}
const findRadioGroup = () => wrapper.findComponent(GlFormRadioGroup);
it('renders radio group', () => {
createComponent();
expect(findRadioGroup().exists()).toBe(true);
});
});

View File

@ -48,6 +48,11 @@ RSpec.describe IconsHelper do
.to eq "<svg class=\"s72\" data-testid=\"#{icon_name}-icon\"><use href=\"#{icons_path}##{icon_name}\"></use></svg>"
end
it 'returns svg icon html + size + variant classes' do
expect(sprite_icon(icon_name, size: 72, variant: 'subtle').to_s)
.to eq "<svg class=\"s72 gl-fill-icon-subtle\" data-testid=\"#{icon_name}-icon\"><use href=\"#{icons_path}##{icon_name}\"></use></svg>"
end
it 'returns svg icon html + size classes + additional class' do
expect(sprite_icon(icon_name, size: 72, css_class: 'icon-danger').to_s)
.to eq "<svg class=\"s72 icon-danger\" data-testid=\"#{icon_name}-icon\"><use href=\"#{icons_path}##{icon_name}\"></use></svg>"

View File

@ -10,6 +10,24 @@ RSpec.describe TreeHelper, feature_category: :source_code_management do
let_it_be(:user) { create(:user) }
describe '#tree_edit_branch' do
let(:ref) { 'main' }
before do
allow(helper).to receive(:patch_branch_name).and_return('patch-1')
end
it 'returns nil when cannot edit tree' do
allow(helper).to receive(:can_edit_tree?).and_return(false)
expect(helper.tree_edit_branch(project, ref)).to be_nil
end
it 'returns the patch branch name when can edit tree' do
allow(helper).to receive(:can_edit_tree?).and_return(true)
expect(helper.tree_edit_branch(project, ref)).to eq('patch-1')
end
end
describe '#breadcrumb_data_attributes' do
let(:ref) { 'main' }
let(:base_attributes) do

View File

@ -2,7 +2,8 @@
require 'spec_helper'
RSpec.describe Gitlab::BackgroundMigration::DeleteOrphanedCiRunnerProjects, feature_category: :runner, migration: :gitlab_ci do
RSpec.describe Gitlab::BackgroundMigration::DeleteOrphanedCiRunnerProjects, feature_category: :runner,
migration: :gitlab_ci, schema: 20250113153424 do
let(:connection) { Ci::ApplicationRecord.connection }
let(:runners) { table(:ci_runners, database: :ci, primary_key: :id) }
let(:runner_projects) { table(:ci_runner_projects, database: :ci, primary_key: :id) }

View File

@ -89,16 +89,16 @@ RSpec.describe Notify, feature_category: :code_review_workflow do
end
end
describe 'with HTML-encoded entities' do
describe 'with non-ASCII characters' do
before do
described_class.test_email('test@test.com', 'Subject', 'Some body with &mdash;').deliver
described_class.test_email('test@test.com', 'Subject', 'Some body with 中文 &mdash;').deliver
end
subject { ActionMailer::Base.deliveries.last }
it 'retains 7bit encoding' do
expect(subject.body.ascii_only?).to eq(true)
expect(subject.body.encoding).to eq('7bit')
it 'removes HTML encoding and uses UTF-8 charset' do
expect(subject.charset).to eq('UTF-8')
expect(subject.body).to include('中文 —')
end
end

View File

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