({
+ selected: ['option', 'option-two'],
+});
+
+const Template = () => ({
+ components: { MultipleChoiceSelector, MultipleChoiceSelectorItem, GlBadge, GlIcon },
+ data,
+ template: `
+
+
+
+ Option name
+ Beta
+
+
+
+
+
+
+
+
+ `,
+});
+
+export const Default = Template.bind({});
diff --git a/app/assets/javascripts/vue_shared/components/multiple_choice_selector.vue b/app/assets/javascripts/vue_shared/components/multiple_choice_selector.vue
new file mode 100644
index 00000000000..f0090417206
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/multiple_choice_selector.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
diff --git a/app/assets/javascripts/vue_shared/components/multiple_choice_selector_item.vue b/app/assets/javascripts/vue_shared/components/multiple_choice_selector_item.vue
new file mode 100644
index 00000000000..766b4c41a23
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/multiple_choice_selector_item.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+
+ {{ disabledMessage }}
+
+
+ {{ description }}
+
+
+
+
diff --git a/app/assets/javascripts/vue_shared/components/single_choice_selector.stories.js b/app/assets/javascripts/vue_shared/components/single_choice_selector.stories.js
new file mode 100644
index 00000000000..8f841de7d8e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/single_choice_selector.stories.js
@@ -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: `
+
+
+
+ Option name
+ Beta
+
+
+
+
+
+
+
+
+ `,
+});
+
+export const Default = Template.bind({});
diff --git a/app/assets/javascripts/vue_shared/components/single_choice_selector.vue b/app/assets/javascripts/vue_shared/components/single_choice_selector.vue
new file mode 100644
index 00000000000..b5c9a5d2cef
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/single_choice_selector.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
diff --git a/app/assets/javascripts/vue_shared/components/single_choice_selector_item.vue b/app/assets/javascripts/vue_shared/components/single_choice_selector_item.vue
new file mode 100644
index 00000000000..95c56c769f8
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/single_choice_selector_item.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+
+ {{ disabledMessage }}
+
+
+ {{ description }}
+
+
+
+
diff --git a/app/assets/stylesheets/components/_index.scss b/app/assets/stylesheets/components/_index.scss
index 16ddec324e8..018b1fc49e9 100644
--- a/app/assets/stylesheets/components/_index.scss
+++ b/app/assets/stylesheets/components/_index.scss
@@ -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';
diff --git a/app/assets/stylesheets/components/multiple_choice_selector.scss b/app/assets/stylesheets/components/multiple_choice_selector.scss
new file mode 100644
index 00000000000..d4b4b7908f3
--- /dev/null
+++ b/app/assets/stylesheets/components/multiple_choice_selector.scss
@@ -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;
+ }
+}
diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb
index d5cd6ce11ad..583f477a0ad 100644
--- a/app/helpers/icons_helper.rb
+++ b/app/helpers/icons_helper.rb
@@ -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(
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index aff98431c29..006ccc2b5ef 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -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:
diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml
index 42e898dad9d..3607919e996 100644
--- a/app/views/shared/notes/_note.html.haml
+++ b/app/views/shared/notes/_note.html.haml
@@ -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
diff --git a/config/initializers/premailer.rb b/config/initializers/premailer.rb
index ad80d80f079..687f56eb884 100644
--- a/config/initializers/premailer.rb
+++ b/config/initializers/premailer.rb
@@ -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]
)
diff --git a/db/docs/batched_background_migrations/delete_orphaned_ci_runner_projects.yml b/db/docs/batched_background_migrations/delete_orphaned_ci_runner_projects.yml
index fac7a426beb..6ac7c97a4de 100644
--- a/db/docs/batched_background_migrations/delete_orphaned_ci_runner_projects.yml
+++ b/db/docs/batched_background_migrations/delete_orphaned_ci_runner_projects.yml
@@ -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
diff --git a/db/docs/batched_background_migrations/delete_orphaned_partitioned_ci_runner_machine_records.yml b/db/docs/batched_background_migrations/delete_orphaned_partitioned_ci_runner_machine_records.yml
index 65fa3397b83..c62508c9db9 100644
--- a/db/docs/batched_background_migrations/delete_orphaned_partitioned_ci_runner_machine_records.yml
+++ b/db/docs/batched_background_migrations/delete_orphaned_partitioned_ci_runner_machine_records.yml
@@ -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
diff --git a/db/post_migrate/20250113153424_finalize_delete_orphaned_ci_runner_projects.rb b/db/post_migrate/20250113153424_finalize_delete_orphaned_ci_runner_projects.rb
new file mode 100644
index 00000000000..995ea5311ef
--- /dev/null
+++ b/db/post_migrate/20250113153424_finalize_delete_orphaned_ci_runner_projects.rb
@@ -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
diff --git a/db/post_migrate/20250113154828_validate_foreign_key_for_runner_id_in_ci_runner_projects.rb b/db/post_migrate/20250113154828_validate_foreign_key_for_runner_id_in_ci_runner_projects.rb
new file mode 100644
index 00000000000..871c6c4e759
--- /dev/null
+++ b/db/post_migrate/20250113154828_validate_foreign_key_for_runner_id_in_ci_runner_projects.rb
@@ -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
diff --git a/db/post_migrate/20250113164152_requeue_delete_orphaned_partitioned_ci_runner_machine_records.rb b/db/post_migrate/20250113164152_requeue_delete_orphaned_partitioned_ci_runner_machine_records.rb
new file mode 100644
index 00000000000..08248e50c34
--- /dev/null
+++ b/db/post_migrate/20250113164152_requeue_delete_orphaned_partitioned_ci_runner_machine_records.rb
@@ -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
diff --git a/db/schema_migrations/20250113153424 b/db/schema_migrations/20250113153424
new file mode 100644
index 00000000000..32881e5cf38
--- /dev/null
+++ b/db/schema_migrations/20250113153424
@@ -0,0 +1 @@
+1a31c384457bee054e5b2b0c3d9029282075c5a00699d8a1ba995b3fda461e72
\ No newline at end of file
diff --git a/db/schema_migrations/20250113154828 b/db/schema_migrations/20250113154828
new file mode 100644
index 00000000000..84da02935ef
--- /dev/null
+++ b/db/schema_migrations/20250113154828
@@ -0,0 +1 @@
+e1e13c73f5443941b257172c9fda0cc463928685d490f58baaf4a3f5e0ed9d24
\ No newline at end of file
diff --git a/db/schema_migrations/20250113164152 b/db/schema_migrations/20250113164152
new file mode 100644
index 00000000000..4db6b12dc76
--- /dev/null
+++ b/db/schema_migrations/20250113164152
@@ -0,0 +1 @@
+6cc3cc278a9b65bd2b1aa56e6ea9b70ec194083090d6b60d5f0b8b6c3641f4d2
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index a75373665d0..69a45e3c84a 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -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;
diff --git a/doc/ci/ci_cd_for_external_repos/index.md b/doc/ci/ci_cd_for_external_repos/index.md
index 21d48f2b36e..4d8e8dfa0cc 100644
--- a/doc/ci/ci_cd_for_external_repos/index.md
+++ b/doc/ci/ci_cd_for_external_repos/index.md
@@ -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
diff --git a/doc/update/versions/gitlab_17_changes.md b/doc/update/versions/gitlab_17_changes.md
index dbdc0d1cb64..4c35fa7d609 100644
--- a/doc/update/versions/gitlab_17_changes.md
+++ b/doc/update/versions/gitlab_17_changes.md
@@ -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:
diff --git a/doc/user/duo_workflow/index.md b/doc/user/duo_workflow/index.md
index b7f63f672e5..fc77f4a78b8 100644
--- a/doc/user/duo_workflow/index.md
+++ b/doc/user/duo_workflow/index.md
@@ -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:
Cmd +
,
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:
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index c40bf7cb772..5cca4e4d421 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -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 ""
diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js
index 73c484a3e1c..afe21edbc81 100644
--- a/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js
+++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js
@@ -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', () => {
diff --git a/spec/frontend/notes/components/diff_with_note_spec.js b/spec/frontend/notes/components/diff_with_note_spec.js
index 508f2ced4c4..8d56011db3b 100644
--- a/spec/frontend/notes/components/diff_with_note_spec.js
+++ b/spec/frontend/notes/components/diff_with_note_spec.js
@@ -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);
+ });
});
});
diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js
index 4230f98c77c..eff88c26d7e 100644
--- a/spec/frontend/notes/components/noteable_discussion_spec.js
+++ b/spec/frontend/notes/components/noteable_discussion_spec.js
@@ -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();
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/container_protection_tag_rule_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/container_protection_tag_rule_form_spec.js
new file mode 100644
index 00000000000..ae2137eaa94
--- /dev/null
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/container_protection_tag_rule_form_spec.js
@@ -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);
+ },
+ );
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/container_protection_tag_rules_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/container_protection_tag_rules_spec.js
index f58e43fd22e..e7c52404d4b 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/container_protection_tag_rules_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/container_protection_tag_rules_spec.js
@@ -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', () => {
diff --git a/spec/frontend/repository/components/commit_changes_modal_spec.js b/spec/frontend/repository/components/commit_changes_modal_spec.js
index 070921ef2f4..64b297be486 100644
--- a/spec/frontend/repository/components/commit_changes_modal_spec.js
+++ b/spec/frontend/repository/components/commit_changes_modal_spec.js
@@ -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}
diff --git a/spec/frontend/repository/components/new_directory_modal_spec.js b/spec/frontend/repository/components/new_directory_modal_spec.js
index 9918c6bf7f4..99647670d28 100644
--- a/spec/frontend/repository/components/new_directory_modal_spec.js
+++ b/spec/frontend/repository/components/new_directory_modal_spec.js
@@ -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 () => {
diff --git a/spec/frontend/todos/components/toggle_snoozed_status_spec.js b/spec/frontend/todos/components/toggle_snoozed_status_spec.js
index 15e7e1ba85c..a4a35314699 100644
--- a/spec/frontend/todos/components/toggle_snoozed_status_spec.js
+++ b/spec/frontend/todos/components/toggle_snoozed_status_spec.js
@@ -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 () => {
diff --git a/spec/frontend/vue_shared/components/multiple_choice_selector_item_spec.js b/spec/frontend/vue_shared/components/multiple_choice_selector_item_spec.js
new file mode 100644
index 00000000000..cf90635a90c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/multiple_choice_selector_item_spec.js
@@ -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');
+ });
+});
diff --git a/spec/frontend/vue_shared/components/multiple_choice_selector_spec.js b/spec/frontend/vue_shared/components/multiple_choice_selector_spec.js
new file mode 100644
index 00000000000..8727ab6a2e2
--- /dev/null
+++ b/spec/frontend/vue_shared/components/multiple_choice_selector_spec.js
@@ -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);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/single_choice_selector_item_spec.js b/spec/frontend/vue_shared/components/single_choice_selector_item_spec.js
new file mode 100644
index 00000000000..02d46071c3a
--- /dev/null
+++ b/spec/frontend/vue_shared/components/single_choice_selector_item_spec.js
@@ -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');
+ });
+});
diff --git a/spec/frontend/vue_shared/components/single_choice_selector_spec.js b/spec/frontend/vue_shared/components/single_choice_selector_spec.js
new file mode 100644
index 00000000000..67c6317d373
--- /dev/null
+++ b/spec/frontend/vue_shared/components/single_choice_selector_spec.js
@@ -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);
+ });
+});
diff --git a/spec/helpers/icons_helper_spec.rb b/spec/helpers/icons_helper_spec.rb
index a9059ea52d9..5f8c960ed77 100644
--- a/spec/helpers/icons_helper_spec.rb
+++ b/spec/helpers/icons_helper_spec.rb
@@ -48,6 +48,11 @@ RSpec.describe IconsHelper do
.to eq "
"
end
+ it 'returns svg icon html + size + variant classes' do
+ expect(sprite_icon(icon_name, size: 72, variant: 'subtle').to_s)
+ .to eq "
"
+ 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 "
"
diff --git a/spec/helpers/tree_helper_spec.rb b/spec/helpers/tree_helper_spec.rb
index a8be915efb2..4b853f71712 100644
--- a/spec/helpers/tree_helper_spec.rb
+++ b/spec/helpers/tree_helper_spec.rb
@@ -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
diff --git a/spec/lib/gitlab/background_migration/delete_orphaned_ci_runner_projects_spec.rb b/spec/lib/gitlab/background_migration/delete_orphaned_ci_runner_projects_spec.rb
index 82d3979893f..967eb006f8c 100644
--- a/spec/lib/gitlab/background_migration/delete_orphaned_ci_runner_projects_spec.rb
+++ b/spec/lib/gitlab/background_migration/delete_orphaned_ci_runner_projects_spec.rb
@@ -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) }
diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index 9dcb0cdf103..f845a397351 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -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 —').deliver
+ described_class.test_email('test@test.com', 'Subject', 'Some body with 中文 —').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
diff --git a/spec/migrations/20250113164152_requeue_delete_orphaned_partitioned_ci_runner_machine_records_spec.rb b/spec/migrations/20250113164152_requeue_delete_orphaned_partitioned_ci_runner_machine_records_spec.rb
new file mode 100644
index 00000000000..959924f41c7
--- /dev/null
+++ b/spec/migrations/20250113164152_requeue_delete_orphaned_partitioned_ci_runner_machine_records_spec.rb
@@ -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