diff --git a/.gitlab/ci/qa-common/variables.gitlab-ci.yml b/.gitlab/ci/qa-common/variables.gitlab-ci.yml index b5a74e30fe2..dd93eb4f180 100644 --- a/.gitlab/ci/qa-common/variables.gitlab-ci.yml +++ b/.gitlab/ci/qa-common/variables.gitlab-ci.yml @@ -16,4 +16,4 @@ variables: QA_OMNIBUS_MR_TESTS: "only-smoke" # Retry failed specs in separate process QA_RETRY_FAILED_SPECS: "true" - GITLAB_HELM_CHART_REF: "ec4eb83b98572fd2721516df00799858512b0538" # helm chart ref used by test-on-cng pipeline + GITLAB_HELM_CHART_REF: "8590da829037a326808e425488919d374ed36cd2" # helm chart ref used by test-on-cng pipeline diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue index 74f4fb53a09..42800eb3e1a 100644 --- a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue +++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue @@ -67,7 +67,7 @@ export default { diff --git a/app/assets/javascripts/content_editor/services/table_of_contents_utils.js b/app/assets/javascripts/content_editor/services/table_of_contents_utils.js index 49737610635..455f053f528 100644 --- a/app/assets/javascripts/content_editor/services/table_of_contents_utils.js +++ b/app/assets/javascripts/content_editor/services/table_of_contents_utils.js @@ -2,16 +2,17 @@ class TOCHeading { parent = null; subHeadings = []; - constructor(text) { + constructor(text, href) { this.text = text; + this.href = href; } get level() { return this.parent ? this.parent.level + 1 : 0; } - addSubHeading(text) { - const heading = new TOCHeading(text); + addSubHeading(text, href) { + const heading = new TOCHeading(text, href); heading.parent = this; this.subHeadings.push(heading); return heading; @@ -46,6 +47,7 @@ class TOCHeading { toJSON() { return { text: this.text, + href: this.href, level: this.level, subHeadings: this.subHeadings.map((subHeading) => subHeading.toJSON()), }; @@ -76,7 +78,7 @@ export function toTree(headings) { if (heading.level <= currentHeading.level) { currentHeading = currentHeading.parentAt(heading.level - 1); } - currentHeading = (currentHeading || tree).addSubHeading(heading.text); + currentHeading = (currentHeading || tree).addSubHeading(heading.text, heading.href); } return tree.flattenIfEmpty().toJSON(); @@ -98,3 +100,15 @@ export function getHeadings(editor) { return toTree(headings).subHeadings; } + +export function getHeadingsFromDOM(containerElement) { + const headingSelectors = 'h1, h2, h3, h4, h5, h6'; + + return toTree( + [...containerElement.querySelectorAll(headingSelectors)].map((heading) => ({ + level: parseInt(heading.tagName[1], 10), + text: heading.textContent.trim(), + href: heading.querySelector('a').getAttribute('href'), + })), + ).subHeadings; +} diff --git a/app/assets/javascripts/pages/shared/wikis/components/table_of_contents.vue b/app/assets/javascripts/pages/shared/wikis/components/table_of_contents.vue new file mode 100644 index 00000000000..7b3d7608b7a --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/components/table_of_contents.vue @@ -0,0 +1,30 @@ + + diff --git a/app/assets/javascripts/pages/shared/wikis/components/table_of_contents_heading.vue b/app/assets/javascripts/pages/shared/wikis/components/table_of_contents_heading.vue new file mode 100644 index 00000000000..16f7d974a9c --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/components/table_of_contents_heading.vue @@ -0,0 +1,26 @@ + + diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue index 99a64409960..fb5e3daa534 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue @@ -1,4 +1,5 @@ @@ -42,7 +52,7 @@ export default { :work-item-type="workItemType" :show-labels="showLabels" @removeChild="$emit('removeChild', $event)" - @click="$emit('click', Object.assign($event, { childItem: child }))" + @click="onClick($event, child)" /> diff --git a/app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss b/app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss index d857d1f4f1c..ea16c38803b 100644 --- a/app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss +++ b/app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss @@ -18,8 +18,8 @@ } .upload-dropzone-card { - transition: background $gl-transition-duration-medium $general-hover-transition-curve, - border $gl-transition-duration-medium $general-hover-transition-curve; + @apply gl-transition-[background,border]; + color: $gl-text-color; &:hover, @@ -53,7 +53,9 @@ .upload-dropzone-fade-enter-active, .upload-dropzone-fade-leave-active { - transition: opacity $general-hover-transition-duration $general-hover-transition-curve; + @apply gl-transition-opacity; + @apply gl-duration-fast; + @apply gl-ease-linear; } .upload-dropzone-fade-enter, diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 0ee43eaffca..57b450b83dc 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -105,7 +105,7 @@ box-shadow: none; width: 100%; resize: none !important; - transition: box-shadow $gl-transition-duration-medium ease; + @apply gl-transition-box-shadow; } .md-suggestion-diff { diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 0e55a98ed9c..002c5d3e077 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -435,7 +435,7 @@ } @mixin side-panel-toggle { - transition: width $gl-transition-duration-medium; + @apply gl-transition-width; height: $toggle-sidebar-height; padding: 0 $gl-padding; background-color: $gray-10; diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 3eaf5fc5f9f..db3c26f2691 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -17,7 +17,7 @@ } .page-initialised .content-wrapper { - transition: padding $gl-transition-duration-medium; + @apply gl-transition-padding; } .right-sidebar-collapsed { @@ -134,7 +134,7 @@ @include maintain-sidebar-dimensions; width: 0; padding: 0; - transition: width $gl-transition-duration-medium; + @apply gl-transition-width; &.right-sidebar-expanded { @include maintain-sidebar-dimensions; @@ -321,10 +321,10 @@ position: fixed; bottom: calc(#{$calc-application-footer-height} + var(--mr-review-bar-height)); right: 0; - transition: width $gl-transition-duration-medium; background-color: $white; z-index: 200; overflow: hidden; + @apply gl-transition-width; } .right-sidebar { diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss index 5fbc060a00e..3554aa6e613 100644 --- a/app/assets/stylesheets/framework/super_sidebar.scss +++ b/app/assets/stylesheets/framework/super_sidebar.scss @@ -1,5 +1,5 @@ $super-sidebar-transition-delay: 0.4s; -$super-sidebar-transition-duration: $gl-transition-duration-medium; +$super-sidebar-transition-duration: 0.2s; $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; $command-palette-spacing: px-to-rem(14px); @@ -335,7 +335,7 @@ $command-palette-spacing: px-to-rem(14px); } @media (prefers-reduced-motion: no-preference) { - transition: transform $super-sidebar-transition-duration; + @apply gl-transition-transform; } .user-bar { @@ -523,7 +523,7 @@ $command-palette-spacing: px-to-rem(14px); padding-left: 0; @media (prefers-reduced-motion: no-preference) { - transition: padding-left $super-sidebar-transition-duration; + @apply gl-transition-padding; } &:not(.page-with-super-sidebar-collapsed) { @@ -661,7 +661,9 @@ $command-palette-spacing: px-to-rem(14px); .transition-opacity-on-hover--context { .transition-opacity-on-hover--target { - transition: opacity $gl-transition-duration-fast linear; + @apply gl-transition-opacity; + @apply gl-duration-fast; + @apply gl-ease-linear; &:hover { transition-delay: $super-sidebar-transition-delay; diff --git a/app/assets/stylesheets/framework/top_bar.scss b/app/assets/stylesheets/framework/top_bar.scss index c6f2f434887..b8423613399 100644 --- a/app/assets/stylesheets/framework/top_bar.scss +++ b/app/assets/stylesheets/framework/top_bar.scss @@ -17,8 +17,6 @@ } @media (prefers-reduced-motion: no-preference) { - transition: left $gl-transition-duration-medium, - right $gl-transition-duration-medium, - width $gl-transition-duration-medium; + @apply gl-transition-[width,left,right]; } } diff --git a/app/assets/stylesheets/framework/vue_transitions.scss b/app/assets/stylesheets/framework/vue_transitions.scss index e3ac615234c..a9acb823cf3 100644 --- a/app/assets/stylesheets/framework/vue_transitions.scss +++ b/app/assets/stylesheets/framework/vue_transitions.scss @@ -2,7 +2,7 @@ .fade-leave-active, .fade-in-enter-active, .fade-out-leave-active { - transition: opacity $gl-transition-duration-medium $general-hover-transition-curve; + @apply gl-transition-opacity; } .fade-enter, diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss index e240ba7cf47..242e96a2fa2 100644 --- a/app/assets/stylesheets/page_bundles/boards.scss +++ b/app/assets/stylesheets/page_bundles/boards.scss @@ -11,7 +11,7 @@ flex-direction: column; @include media-breakpoint-up(sm) { - transition: width $gl-transition-duration-medium; + @apply gl-transition-width; width: 100%; &.is-compact { diff --git a/app/assets/stylesheets/page_bundles/design_management.scss b/app/assets/stylesheets/page_bundles/design_management.scss index 9645a7ab5ea..ac2a06f1408 100644 --- a/app/assets/stylesheets/page_bundles/design_management.scss +++ b/app/assets/stylesheets/page_bundles/design_management.scss @@ -182,9 +182,9 @@ $t-gray-a-16-design-pin: rgba($black, 0.16); .design-note { padding: $gl-padding-8; list-style: none; - transition: background $gl-transition-duration-medium $general-hover-transition-curve; border-top-left-radius: $gl-border-radius-base; // same border radius used by .bordered-box border-top-right-radius: $gl-border-radius-base; + @apply gl-transition-background; video { width: 100%; diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index a4a5e5d865a..c9f315a1113 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -999,7 +999,7 @@ padding-right: $right-sidebar-collapsed-width; background: var(--white, $white); border-top: 1px solid var(--border-color, $border-color); - transition: padding $gl-transition-duration-medium; + @apply gl-transition-padding; @media (max-width: map-get($grid-breakpoints, sm)-1) { padding-left: 0; diff --git a/app/assets/stylesheets/page_bundles/wiki.scss b/app/assets/stylesheets/page_bundles/wiki.scss index f1c30ba45f1..309ee921e63 100644 --- a/app/assets/stylesheets/page_bundles/wiki.scss +++ b/app/assets/stylesheets/page_bundles/wiki.scss @@ -19,7 +19,6 @@ } .sidebar-container { - padding: 20px 0; padding-right: 100px; height: 100%; overflow-y: scroll; diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 6ae7b46d978..65ff426abc0 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -14,17 +14,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord ignore_columns %i[instance_administration_project_id instance_administrators_group_id], remove_with: '16.2', remove_after: '2023-06-22' ignore_columns %i[repository_storages], remove_with: '16.8', remove_after: '2023-12-21' ignore_column :required_instance_ci_template, remove_with: '17.1', remove_after: '2024-05-10' - ignore_columns %i[ - container_registry_import_max_tags_count - container_registry_import_max_retries - container_registry_import_start_max_retries - container_registry_import_max_step_duration - container_registry_pre_import_tags_rate - container_registry_pre_import_timeout - container_registry_import_timeout - container_registry_import_target_plan - container_registry_import_created_before - ], remove_with: '17.2', remove_after: '2024-06-24' ignore_column %i[sign_in_text help_text], remove_with: '17.3', remove_after: '2024-08-15' ignore_columns %i[toggle_security_policies_policy_scope lock_toggle_security_policies_policy_scope], remove_with: '17.2', remove_after: '2024-07-12' ignore_columns %i[arkose_labs_verify_api_url], remove_with: '17.4', remove_after: '2024-08-09' diff --git a/app/models/import/source_user_placeholder_reference.rb b/app/models/import/source_user_placeholder_reference.rb new file mode 100644 index 00000000000..66c6647e3d1 --- /dev/null +++ b/app/models/import/source_user_placeholder_reference.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Import + class SourceUserPlaceholderReference < ApplicationRecord + self.table_name = 'import_source_user_placeholder_references' + + belongs_to :source_user, class_name: 'Import::SourceUser' + belongs_to :namespace + + validates :model, :namespace_id, :source_user_id, :user_reference_column, presence: true + validates :numeric_key, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true + validates :composite_key, + json_schema: { filename: 'import_source_user_placeholder_reference_composite_key' }, + allow_nil: true + validate :validate_numeric_or_composite_key_present + + attribute :composite_key, :ind_jsonb + + private + + def validate_numeric_or_composite_key_present + return if numeric_key.present? ^ composite_key.present? + + errors.add(:base, :blank, message: 'numeric_key or composite_key must be present') + end + end +end diff --git a/app/models/project.rb b/app/models/project.rb index def7d6151c0..ecb59cb7e7b 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -342,6 +342,8 @@ class Project < ApplicationRecord has_many :hooks, class_name: 'ProjectHook' has_many :protected_branches has_many :exported_protected_branches + has_many :all_protected_branches, ->(project) { ProtectedBranch.unscope(:where).from_union(project.protected_branches, project.group_protected_branches) }, class_name: 'ProtectedBranch' + has_many :protected_tags has_many :repository_languages, -> { order "share DESC" } has_many :designs, inverse_of: :project, class_name: 'DesignManagement::Design' @@ -1278,8 +1280,8 @@ class Project < ApplicationRecord def preload_protected_branches ActiveRecord::Associations::Preloader.new( - records: [self], - associations: { protected_branches: [:push_access_levels, :merge_access_levels] } + records: [all_protected_branches, protected_branches].flatten, + associations: [:push_access_levels, :merge_access_levels] ).call end @@ -2948,15 +2950,9 @@ class Project < ApplicationRecord end def group_protected_branches - root_namespace.is_a?(Group) ? root_namespace.protected_branches : ProtectedBranch.none - end + return root_namespace.protected_branches if allow_protected_branches_for_group? && root_namespace.is_a?(Group) - def all_protected_branches - if allow_protected_branches_for_group? - @all_protected_branches ||= ProtectedBranch.from_union([protected_branches, group_protected_branches]) - else - protected_branches - end + ProtectedBranch.none end def allow_protected_branches_for_group? diff --git a/app/validators/json_schemas/import_source_user_placeholder_reference_composite_key.json b/app/validators/json_schemas/import_source_user_placeholder_reference_composite_key.json new file mode 100644 index 00000000000..ccfbe0ec527 --- /dev/null +++ b/app/validators/json_schemas/import_source_user_placeholder_reference_composite_key.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Stores composite_key data for imported records that are mapped to placeholder users", + "type": "object", + "minProperties": 1, + "patternProperties": { + ".*": { + "oneOf": [ + { + "type": "string", + "pattern": "^[0-9]+$" + }, + { + "type": "integer" + } + ] + } + } +} diff --git a/app/views/admin/abuse_reports/index.html.haml b/app/views/admin/abuse_reports/index.html.haml index 485c9044366..45c4ae80f71 100644 --- a/app/views/admin/abuse_reports/index.html.haml +++ b/app/views/admin/abuse_reports/index.html.haml @@ -1,7 +1,6 @@ -- page_title _('Abuse Reports') +- page_title _('Abuse reports') -%h1.page-title.gl-font-size-h-display{ data: { event_tracking_load: 'true', event_tracking: 'view_admin_abuse_reports_pageload' } } - = _('Abuse Reports') += render ::Layouts::PageHeadingComponent.new(_('Abuse reports'), options: { data: { event_tracking_load: 'true', event_tracking: 'view_admin_abuse_reports_pageload' } }) #js-abuse-reports-list-app{ data: abuse_reports_list_data(@abuse_reports) } = gl_loading_icon(css_class: 'gl-my-5', size: 'md') diff --git a/app/views/admin/abuse_reports/show.html.haml b/app/views/admin/abuse_reports/show.html.haml index ff9ac6a052c..d44aae04937 100644 --- a/app/views/admin/abuse_reports/show.html.haml +++ b/app/views/admin/abuse_reports/show.html.haml @@ -1,7 +1,7 @@ -- add_to_breadcrumbs _('Abuse Reports'), admin_abuse_reports_path +- add_to_breadcrumbs _('Abuse reports'), admin_abuse_reports_path - breadcrumb_title @abuse_report.user&.name - @content_class = "limit-container-width" unless fluid_layout -- page_title @abuse_report.user&.name, _('Abuse Reports') +- page_title @abuse_report.user&.name, _('Abuse reports') #js-abuse-reports-detail-view{ data: abuse_report_data(@abuse_report) } = gl_loading_icon(css_class: 'gl-my-5', size: 'md') diff --git a/app/views/shared/wikis/_sidebar.html.haml b/app/views/shared/wikis/_sidebar.html.haml index a757c196973..24defe5d30a 100644 --- a/app/views/shared/wikis/_sidebar.html.haml +++ b/app/views/shared/wikis/_sidebar.html.haml @@ -4,14 +4,17 @@ %aside.right-sidebar.right-sidebar-expanded.wiki-sidebar.js-wiki-sidebar.js-right-sidebar{ data: { "offset-top" => "50", "spy" => "affix" }, 'aria-label': _('Wiki') } .sidebar-container - .block.gl-mb-3.gl-mx-5.gl-block.sm:gl-hidden{ class: '!gl-pt-0' } + .block.gl-mb-3.gl-mx-5.gl-mt-5.gl-block.sm:gl-hidden{ class: '!gl-pt-0' } %a.gutter-toggle.gl-float-right.gl-block.md:gl-hidden.js-sidebar-wiki-toggle{ href: "#" } = sprite_icon('chevron-double-lg-right', css_class: 'gl-icon') - if @sidebar_error.present? = render 'shared/alert_info', body: s_('Wiki|The sidebar failed to load. You can reload the page to try again.') - .blocks-container{ class: '!gl-px-3' } + - if !editing + .js-wiki-toc + + .blocks-container{ class: '!gl-px-3 !gl-my-5' } .gl-flex.gl-place-content-between.gl-items-center.gl-pb-3.gl-pr-1{ class: (@sidebar_page ? 'js-wiki-expand-pages-list wiki-list collapsed gl-pl-0' : 'gl-pl-3') } .gl-flex.gl-items-center - if @sidebar_page diff --git a/config/tailwind.config.js b/config/tailwind.config.js index 094d6e35980..6e270a111b6 100644 --- a/config/tailwind.config.js +++ b/config/tailwind.config.js @@ -208,6 +208,7 @@ module.exports = { }, transitionTimingFunction: { ease: 'ease', + linear: 'linear', }, // TODO: Backport to GitLab UI. borderRadius: { @@ -267,8 +268,16 @@ module.exports = { '1/2': '50%', }, transitionProperty: { + transform: 'transform', + background: 'background', + opacity: 'opacity', + left: 'left', + right: 'right', + width: 'width', stroke: 'stroke', + padding: 'padding', 'stroke-opacity': 'stroke-opacity', + 'box-shadow': 'box-shadow', }, transitionTimingFunction: { DEFAULT: 'ease', diff --git a/db/docs/batched_background_migrations/backfill_epic_issues_into_work_item_parent_links.yml b/db/docs/batched_background_migrations/backfill_epic_issues_into_work_item_parent_links.yml index b5bb1eabf2f..598d7f6a235 100644 --- a/db/docs/batched_background_migrations/backfill_epic_issues_into_work_item_parent_links.yml +++ b/db/docs/batched_background_migrations/backfill_epic_issues_into_work_item_parent_links.yml @@ -6,4 +6,4 @@ introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/147509 milestone: '17.1' queued_migration_version: 20240522183910 finalize_after: '2024-06-20' -finalized_by: +finalized_by: 20240701182755 diff --git a/db/docs/import_source_user_placeholder_references.yml b/db/docs/import_source_user_placeholder_references.yml new file mode 100644 index 00000000000..87e1f72fc44 --- /dev/null +++ b/db/docs/import_source_user_placeholder_references.yml @@ -0,0 +1,14 @@ +--- +table_name: import_source_user_placeholder_references +classes: +- Import::SourceUserPlaceholderReference +feature_categories: +- importers +description: Used to map placeholder user references from imported data to real users +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/156241 +milestone: '17.2' +gitlab_schema: gitlab_main_cell +allow_cross_foreign_keys: +- gitlab_main_clusterwide +sharding_key: + namespace_id: namespaces diff --git a/db/migrate/20240612034702_create_import_source_user_placeholder_reference.rb b/db/migrate/20240612034702_create_import_source_user_placeholder_reference.rb new file mode 100644 index 00000000000..0364d632200 --- /dev/null +++ b/db/migrate/20240612034702_create_import_source_user_placeholder_reference.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class CreateImportSourceUserPlaceholderReference < Gitlab::Database::Migration[2.2] + milestone '17.2' + + enable_lock_retries! + + INDEX_NAME = 'index_import_source_user_placeholder_references_on_source_user_' + + def change + create_table :import_source_user_placeholder_references do |t| + t.references :source_user, + index: { name: INDEX_NAME }, + null: false, + foreign_key: { to_table: :import_source_users, on_delete: :cascade } + t.references :namespace, null: false, index: true, foreign_key: { on_delete: :cascade } + t.bigint :numeric_key, null: true + t.datetime_with_timezone :created_at, null: false + t.text :model, limit: 150, null: false + t.text :user_reference_column, limit: 50, null: false + t.jsonb :composite_key, null: true + end + end +end diff --git a/db/post_migrate/20240701182755_finalize_backfill_epic_issues_into_work_item_parent_links.rb b/db/post_migrate/20240701182755_finalize_backfill_epic_issues_into_work_item_parent_links.rb new file mode 100644 index 00000000000..8a3cf6a4a07 --- /dev/null +++ b/db/post_migrate/20240701182755_finalize_backfill_epic_issues_into_work_item_parent_links.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class FinalizeBackfillEpicIssuesIntoWorkItemParentLinks < Gitlab::Database::Migration[2.2] + milestone '17.2' + + disable_ddl_transaction! + restrict_gitlab_migration gitlab_schema: :gitlab_main + + def up + ensure_batched_background_migration_is_finished( + job_class_name: 'BackfillEpicIssuesIntoWorkItemParentLinks', + table_name: :epic_issues, + column_name: 'id', + job_arguments: [nil], + finalize: true + ) + end + + def down + # No op + end +end diff --git a/db/schema_migrations/20240612034702 b/db/schema_migrations/20240612034702 new file mode 100644 index 00000000000..4c11c17c15d --- /dev/null +++ b/db/schema_migrations/20240612034702 @@ -0,0 +1 @@ +91e467973c28e98ed562c70ae108f7b5cb1ed0353e3ce0c8b13f052077c75d5a \ No newline at end of file diff --git a/db/schema_migrations/20240701182755 b/db/schema_migrations/20240701182755 new file mode 100644 index 00000000000..963f2b92609 --- /dev/null +++ b/db/schema_migrations/20240701182755 @@ -0,0 +1 @@ +154e67c707fec8122c216af36d26919cc4317089506719ead7a2f2d203a16160 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 6688cf76c18..60ad4eb31d5 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -11167,6 +11167,28 @@ CREATE SEQUENCE import_failures_id_seq ALTER SEQUENCE import_failures_id_seq OWNED BY import_failures.id; +CREATE TABLE import_source_user_placeholder_references ( + id bigint NOT NULL, + source_user_id bigint NOT NULL, + namespace_id bigint NOT NULL, + numeric_key bigint, + created_at timestamp with time zone NOT NULL, + model text NOT NULL, + user_reference_column text NOT NULL, + composite_key jsonb, + CONSTRAINT check_782140eb9d CHECK ((char_length(user_reference_column) <= 50)), + CONSTRAINT check_d17bd9dd4d CHECK ((char_length(model) <= 150)) +); + +CREATE SEQUENCE import_source_user_placeholder_references_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE import_source_user_placeholder_references_id_seq OWNED BY import_source_user_placeholder_references.id; + CREATE TABLE import_source_users ( id bigint NOT NULL, placeholder_user_id bigint, @@ -20801,6 +20823,8 @@ ALTER TABLE ONLY import_export_uploads ALTER COLUMN id SET DEFAULT nextval('impo ALTER TABLE ONLY import_failures ALTER COLUMN id SET DEFAULT nextval('import_failures_id_seq'::regclass); +ALTER TABLE ONLY import_source_user_placeholder_references ALTER COLUMN id SET DEFAULT nextval('import_source_user_placeholder_references_id_seq'::regclass); + ALTER TABLE ONLY import_source_users ALTER COLUMN id SET DEFAULT nextval('import_source_users_id_seq'::regclass); ALTER TABLE ONLY incident_management_escalation_policies ALTER COLUMN id SET DEFAULT nextval('incident_management_escalation_policies_id_seq'::regclass); @@ -22987,6 +23011,9 @@ ALTER TABLE ONLY import_export_uploads ALTER TABLE ONLY import_failures ADD CONSTRAINT import_failures_pkey PRIMARY KEY (id); +ALTER TABLE ONLY import_source_user_placeholder_references + ADD CONSTRAINT import_source_user_placeholder_references_pkey PRIMARY KEY (id); + ALTER TABLE ONLY import_source_users ADD CONSTRAINT import_source_users_pkey PRIMARY KEY (id); @@ -27322,6 +27349,10 @@ CREATE INDEX index_import_failures_on_project_id_not_null ON import_failures USI CREATE INDEX index_import_failures_on_user_id_not_null ON import_failures USING btree (user_id) WHERE (user_id IS NOT NULL); +CREATE INDEX index_import_source_user_placeholder_references_on_namespace_id ON import_source_user_placeholder_references USING btree (namespace_id); + +CREATE INDEX index_import_source_user_placeholder_references_on_source_user_ ON import_source_user_placeholder_references USING btree (source_user_id); + CREATE INDEX index_import_source_users_on_namespace_id ON import_source_users USING btree (namespace_id); CREATE INDEX index_import_source_users_on_placeholder_user_id ON import_source_users USING btree (placeholder_user_id); @@ -33352,6 +33383,9 @@ ALTER TABLE ONLY namespaces_storage_limit_exclusions ALTER TABLE ONLY users_security_dashboard_projects ADD CONSTRAINT fk_rails_150cd5682c FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; +ALTER TABLE ONLY import_source_user_placeholder_references + ADD CONSTRAINT fk_rails_158995b934 FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE; + ALTER TABLE ONLY import_source_users ADD CONSTRAINT fk_rails_167f82fd95 FOREIGN KEY (reassign_to_user_id) REFERENCES users(id) ON DELETE SET NULL; @@ -34720,6 +34754,9 @@ ALTER TABLE ONLY upload_states ALTER TABLE ONLY epic_metrics ADD CONSTRAINT fk_rails_d071904753 FOREIGN KEY (epic_id) REFERENCES epics(id) ON DELETE CASCADE; +ALTER TABLE ONLY import_source_user_placeholder_references + ADD CONSTRAINT fk_rails_d0b75c434e FOREIGN KEY (source_user_id) REFERENCES import_source_users(id) ON DELETE CASCADE; + ALTER TABLE ONLY subscriptions ADD CONSTRAINT fk_rails_d0c8bda804 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; diff --git a/doc/administration/review_abuse_reports.md b/doc/administration/review_abuse_reports.md index badbf327094..8d33a2a60b4 100644 --- a/doc/administration/review_abuse_reports.md +++ b/doc/administration/review_abuse_reports.md @@ -39,7 +39,7 @@ To find out more about reporting abuse, see To access abuse reports: 1. On the left sidebar, at the bottom, select **Admin Area**. -1. Select **Abuse Reports**. +1. Select **Abuse reports**. There are four ways to resolve an abuse report, with a button for each method: @@ -55,7 +55,7 @@ There are four ways to resolve an abuse report, with a button for each method: - Allows the user to create issues, notes, snippets, and merge requests without being blocked for spam. - Prevents abuse reports from being created for this user. -The following is an example of the **Abuse Reports** page: +The following is an example of the **Abuse reports** page: ![abuse-reports-page-image](img/abuse_reports_page_v13_11.png) @@ -80,7 +80,7 @@ After blocking, you can still either: - Remove the user and report if necessary. - Remove the report. -The following is an example of a blocked user listed on the **Abuse Reports** +The following is an example of a blocked user listed on the **Abuse reports** page: ![abuse-report-blocked-user-image](img/abuse_report_blocked_user.png) diff --git a/doc/development/ai_features/duo_chat.md b/doc/development/ai_features/duo_chat.md index 3dc090f9366..f0320938a20 100644 --- a/doc/development/ai_features/duo_chat.md +++ b/doc/development/ai_features/duo_chat.md @@ -246,7 +246,7 @@ To run the QA Evaluation test locally, the following environment variables must be exported: ```ruby -REAL_AI_REQUEST=1 bundle exec rspec ee/spec/lib/gitlab/llm/completions/chat_real_requests_spec.rb +ANTHROPIC_API_KEY='your-key' VERTEX_AI_PROJECT='your-project-id' REAL_AI_REQUEST=1 bundle exec rspec ee/spec/lib/gitlab/llm/completions/chat_real_requests_spec.rb ``` ## Testing with CI diff --git a/doc/development/ai_features/embeddings.md b/doc/development/ai_features/embeddings.md index 9f22b511e45..35fba125ba8 100644 --- a/doc/development/ai_features/embeddings.md +++ b/doc/development/ai_features/embeddings.md @@ -110,14 +110,10 @@ It respects a rate limit of 450 embeddings per minute. Reach out to `@maddievn` If the following returns 0, all issues for the project have embeddings: -
Expand - ```shell curl "http://localhost:9200/gitlab-development-issues/_count" \ --header "Content-Type: application/json" \ --data '{"query": {"bool": {"filter": [{"term": {"project_id": PROJECT_ID}}], "must_not": [{"exists": {"field": "embedding"}}]}}}' | jq '.count' ``` -
- Replacing `PROJECT_ID` with your project ID. diff --git a/doc/user/application_security/continuous_vulnerability_scanning/index.md b/doc/user/application_security/continuous_vulnerability_scanning/index.md index 065e0f308ec..e6fd86ea825 100644 --- a/doc/user/application_security/continuous_vulnerability_scanning/index.md +++ b/doc/user/application_security/continuous_vulnerability_scanning/index.md @@ -46,12 +46,15 @@ Continuous Vulnerability Scanning supports components with the following [PURL t - `npm` - `nuget` - `pypi` +- `rpm` -Work to support `apk` and `rpm` package URL types is tracked in [issue 428703](https://gitlab.com/gitlab-org/gitlab/-/issues/428703). +Work to support the `apk` package URL type is tracked in [issue 428703](https://gitlab.com/gitlab-org/gitlab/-/issues/428703). Go pseudo versions are not supported. A project dependency that references a Go pseudo version is never considered as affected because this might result in false negatives. +RPM versions containing `^` are not supported. Work to support these versions is tracked in [issue 459969](https://gitlab.com/gitlab-org/gitlab/-/issues/459969). + ## How to generate a CycloneDX SBOM report GitLab offers security analyzers that can generate a [CycloneDX SBOM report](../../../ci/yaml/artifacts_reports.md#artifactsreportscyclonedx) compatible with GitLab: diff --git a/doc/user/application_security/policies/index.md b/doc/user/application_security/policies/index.md index 64ee844cca1..015c9828a16 100644 --- a/doc/user/application_security/policies/index.md +++ b/doc/user/application_security/policies/index.md @@ -31,6 +31,7 @@ The following policy types are available: pipeline or on a specified schedule. - [Merge request approval policy](scan-result-policies.md). Enforce project-level settings and approval rules based on scan results. +- [Pipeline execution policy](pipeline_execution_policies.md). Enforce CI/CD jobs as part of project pipelines. ## Security policy project diff --git a/doc/user/application_security/policies/pipeline_execution_policies.md b/doc/user/application_security/policies/pipeline_execution_policies.md new file mode 100644 index 00000000000..6b6a1c53811 --- /dev/null +++ b/doc/user/application_security/policies/pipeline_execution_policies.md @@ -0,0 +1,91 @@ +--- +stage: Govern +group: Security Policies +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments +--- + +# Pipeline execution policies + +DETAILS: +**Tier:** Ultimate +**Offering:** GitLab.com, Self-managed, GitLab Dedicated + +> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/13266) in GitLab 17.2 [with a flag](../../../administration/feature_flags.md) named `pipeline_execution_policy_type`. Disabled by default. + +Use Pipeline execution policies to enforce CI/CD jobs for all applicable projects. + +- For a video walkthrough, see [Security Policies: Pipeline Execution Policy Type](https://www.youtube.com/watch?v=QQAOpkZ__pA). + +## Pipeline execution policies schema + +The YAML file with pipeline execution policies consists of an array of objects matching pipeline execution +policy schema nested under the `pipeline_execution_policy` key. You can configure a maximum of five +policies under the `pipeline_execution_policy` key. Any other policies configured after +the first five are not applied. + +When you save a new policy, GitLab validates its contents against [this JSON schema](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/app/validators/json_schemas/security_orchestration_policy.json). +If you're not familiar with how to read [JSON schemas](https://json-schema.org/), +the following sections and tables provide an alternative. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `pipeline_execution_policy` | `array` of pipeline execution policy | true | List of pipeline execution policies (maximum five) | + +## Pipeline execution policy schema + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | `string` | true | Name of the policy. Maximum of 255 characters.| +| `description` (optional) | `string` | true | Description of the policy. | +| `enabled` | `boolean` | true | Flag to enable (`true`) or disable (`false`) the policy. | +| `content` | `object` of [`content`](#content-type) | true | Reference to the CI/CD configuration to inject into project pipelines. | +| `pipeline_config_strategy` | `string` | false | Can either be `inject_ci` or `override_project_ci`. Defines the method for merging the policy configuration with the project pipeline. `inject_ci` preserves the project CI configuration and injects additional jobs from the policy. Having multiple policies enabled injects all jobs additively. `override_project_ci` replaces the project CI configuration and keeps only the policy jobs in the pipeline. | +| `policy_scope` | `object` of [`policy_scope`](#policy_scope-scope-type) | false | Scopes the policy based on compliance framework labels or projects you define. | + +Note the following: + +- Jobs variables from pipeline execution policies take precedence over the project's CI/CD configuration. +- Users triggering a pipeline must have at least read access to CI file specified in `content`. +- Pipeline execution policy jobs can be assigned to one of the two reserved stages: + - `.pipeline-policy-pre` at the beginning of the pipeline, before the `.pre` stage. + - `.pipeline-policy-post` at the very end of the pipeline, after the .post stage. +- Injecting jobs in any of the reserved stages is guaranteed to always work. Execution policy jobs can also be assigned to any standard (build, test, deploy) or user-declared stages. However, in this case, the jobs may be ignored depending on the project pipeline configuration. +- It is not possible to assign jobs to reserved stages outside of a pipeline execution policy. + +### `content` type + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `project` | `string` | true | The full GitLab project path to a project on the same GitLab instance. | +| `file` | `string` | true | A full file path relative to the root directory (/). The YAML files must have the `.yml` or `.yaml` extension. | +| `ref` | `string` | false | The ref to retrieve the file from. Defaults to the HEAD of the project when not specified. | + +### `policy_scope` scope type + +| Field | Type | Possible values | Description | +|-------|------|-----------------|-------------| +| `compliance_frameworks` | `array` | | List of IDs of the compliance frameworks in scope of enforcement, in an array of objects with key `id`. | +| `projects` | `object` | `including`, `excluding` | Use `excluding:` or `including:` then list the IDs of the projects you wish to include or exclude, in an array of objects with key `id`. | + +### Example security policies project + +You can use the following example in a `.gitlab/security-policies/policy.yml` file stored in a +[security policy project](index.md#security-policy-project): + +```yaml +--- +pipeline_execution_policy: +- name: My pipeline execution policy + description: Enforces CI/CD jobs + enabled: true + pipeline_config_strategy: override_project_ci + content: + include: + - project: verify-issue-469027/policy-ci + file: policy-ci.yml + ref: main # optional + policy_scope: + projects: + including: + - id: 361 +``` diff --git a/lib/api/branches.rb b/lib/api/branches.rb index c3a3058b95b..8d0dff20dba 100644 --- a/lib/api/branches.rb +++ b/lib/api/branches.rb @@ -48,8 +48,6 @@ module API end get ':id/repository/branches', urgency: :low do cache_action([user_project, :branches, current_user, declared_params], expires_in: 30.seconds) do - user_project.preload_protected_branches - repository = user_project.repository branches_finder = BranchesFinder.new(repository, declared_params(include_missing: false)) @@ -57,6 +55,7 @@ module API merged_branch_names = repository.merged_branch_names(branches.map(&:name)) + user_project.preload_protected_branches if branches.present? present_cached( branches, with: Entities::Branch, diff --git a/lib/api/entities/branch.rb b/lib/api/entities/branch.rb index 7a6a3da6e69..327aa0ffc22 100644 --- a/lib/api/entities/branch.rb +++ b/lib/api/entities/branch.rb @@ -36,7 +36,7 @@ module API type: 'boolean', example: true } do |repo_branch, options| - ::ProtectedBranch.developers_can?(:push, repo_branch.name, protected_refs: options[:project].protected_branches) + ::ProtectedBranch.developers_can?(:push, repo_branch.name, protected_refs: options[:project].all_protected_branches) end expose :developers_can_merge, @@ -44,7 +44,7 @@ module API type: 'boolean', example: true } do |repo_branch, options| - ::ProtectedBranch.developers_can?(:merge, repo_branch.name, protected_refs: options[:project].protected_branches) + ::ProtectedBranch.developers_can?(:merge, repo_branch.name, protected_refs: options[:project].all_protected_branches) end expose :can_push, diff --git a/lib/gitlab/pagination/gitaly_keyset_pager.rb b/lib/gitlab/pagination/gitaly_keyset_pager.rb index bec38c44c4c..f1f3e1a053f 100644 --- a/lib/gitlab/pagination/gitaly_keyset_pager.rb +++ b/lib/gitlab/pagination/gitaly_keyset_pager.rb @@ -79,7 +79,7 @@ module Gitlab end def apply_headers(records, next_cursor) - if records.count == params[:per_page] + if records.count == params[:per_page] && next_cursor.present? Gitlab::Pagination::Keyset::HeaderBuilder .new(request_context) .add_next_page_header( diff --git a/lib/sidebars/admin/menus/abuse_reports_menu.rb b/lib/sidebars/admin/menus/abuse_reports_menu.rb index 72f4d6e6590..b03d37b5b2f 100644 --- a/lib/sidebars/admin/menus/abuse_reports_menu.rb +++ b/lib/sidebars/admin/menus/abuse_reports_menu.rb @@ -11,7 +11,7 @@ module Sidebars override :title def title - s_('Admin|Abuse Reports') + s_('Admin|Abuse reports') end override :sprite_icon diff --git a/locale/gitlab.pot b/locale/gitlab.pot index f7c8b3513b9..08bd73ae78c 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2421,9 +2421,6 @@ msgstr "" msgid "About your company" msgstr "" -msgid "Abuse Reports" -msgstr "" - msgid "Abuse reports" msgstr "" @@ -4650,7 +4647,7 @@ msgstr "" msgid "Admin|AI-Powered Features" msgstr "" -msgid "Admin|Abuse Reports" +msgid "Admin|Abuse reports" msgstr "" msgid "Admin|Additional users must be reviewed and approved by a system administrator. Learn more about %{help_link_start}usage caps%{help_link_end}." @@ -36152,6 +36149,9 @@ msgstr[1] "" msgid "On the left sidebar, select %{compliance_center_link} to view them." msgstr "" +msgid "On this page" +msgstr "" + msgid "On track" msgstr "" diff --git a/spec/factories/import_source_user_placeholder_references.rb b/spec/factories/import_source_user_placeholder_references.rb new file mode 100644 index 00000000000..7aa19ad0a5a --- /dev/null +++ b/spec/factories/import_source_user_placeholder_references.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :import_source_user_placeholder_reference, class: 'Import::SourceUserPlaceholderReference' do + source_user factory: :import_source_user + namespace + model { 'Note' } + user_reference_column { 'author_id' } + numeric_key { 1 } + end +end diff --git a/spec/features/gitlab_experiments_spec.rb b/spec/features/gitlab_experiments_spec.rb index 0d0afa801c8..434c036d037 100644 --- a/spec/features/gitlab_experiments_spec.rb +++ b/spec/features/gitlab_experiments_spec.rb @@ -29,7 +29,7 @@ RSpec.describe "Gitlab::Experiment", :js, feature_category: :activation do visit admin_abuse_reports_path - expect(page).to have_content('Abuse Reports') + expect(page).to have_content('Abuse reports') published_experiments = page.evaluate_script('window.gl.experiments') expect(published_experiments).to include({ diff --git a/spec/frontend/content_editor/services/table_of_contents_utils_spec.js b/spec/frontend/content_editor/services/table_of_contents_utils_spec.js index f3d4424f48d..bd64e7df4b3 100644 --- a/spec/frontend/content_editor/services/table_of_contents_utils_spec.js +++ b/spec/frontend/content_editor/services/table_of_contents_utils_spec.js @@ -1,5 +1,10 @@ import Heading from '~/content_editor/extensions/heading'; -import { toTree, getHeadings, fillEmpty } from '~/content_editor/services/table_of_contents_utils'; +import { + toTree, + getHeadings, + fillEmpty, + getHeadingsFromDOM, +} from '~/content_editor/services/table_of_contents_utils'; import { createTestEditor, createDocBuilder } from '../test_utils'; describe('content_editor/services/table_of_content_utils', () => { @@ -144,4 +149,57 @@ describe('content_editor/services/table_of_content_utils', () => { ]); }); }); + + describe('getHeadingsFromDOM', () => { + it('gets all headings as a tree in a DOM element', () => { + const element = document.createElement('div'); + element.innerHTML = ` +

Heading 1

+

Heading 1.1

+

Heading 1.1.1

+

Heading 1.2

+

Heading 1.2.1

+

Heading 1.3

+

Heading 1.4

+

Heading 1.4.1

+

Heading 2

+ `; + + expect(getHeadingsFromDOM(element)).toEqual([ + { + href: '#heading-1', + level: 1, + subHeadings: [ + { + href: '#heading-1-1', + level: 2, + subHeadings: [ + { href: '#heading-1-1-1', level: 3, subHeadings: [], text: 'Heading 1.1.1' }, + ], + text: 'Heading 1.1', + }, + { + href: '#heading-1-2', + level: 2, + subHeadings: [ + { href: '#heading-1-2-1', level: 3, subHeadings: [], text: 'Heading 1.2.1' }, + ], + text: 'Heading 1.2', + }, + { href: '#heading-1-3', level: 2, subHeadings: [], text: 'Heading 1.3' }, + { + href: '#heading-1-4', + level: 2, + subHeadings: [ + { href: '#heading-1-4-1', level: 3, subHeadings: [], text: 'Heading 1.4.1' }, + ], + text: 'Heading 1.4', + }, + ], + text: 'Heading 1', + }, + { href: '#heading-2', level: 1, subHeadings: [], text: 'Heading 2' }, + ]); + }); + }); }); diff --git a/spec/frontend/notes/stores/mutation_spec.js b/spec/frontend/notes/stores/mutation_spec.js index a588957dd0e..b3f2804a4a0 100644 --- a/spec/frontend/notes/stores/mutation_spec.js +++ b/spec/frontend/notes/stores/mutation_spec.js @@ -1,4 +1,3 @@ -import Vue from 'vue'; import { DISCUSSION_NOTE, ASC, DESC } from '~/notes/constants'; import mutations from '~/notes/stores/mutations'; import { @@ -598,13 +597,15 @@ describe('Notes Store mutations', () => { }); it('keeps reactivity of discussion', () => { - const state = {}; - Vue.set(state, 'discussions', [ - { - id: 1, - expanded: false, - }, - ]); + const state = { + discussions: [ + { + id: 1, + expanded: false, + }, + ], + }; + const discussion = state.discussions[0]; mutations.SET_DISCUSSION_DIFF_LINES(state, { diff --git a/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js index 92fb7a7e00f..629105473cd 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js @@ -74,7 +74,7 @@ describe('pages/shared/wikis/components/wiki_content', () => { it('calls renderGFM after nextTick', async () => { await nextTick(); - expect(renderGFM).toHaveBeenCalledWith(wrapper.element); + expect(renderGFM).toHaveBeenCalled(); }); it('handles hash after render', async () => { diff --git a/spec/frontend/work_items/components/work_item_links/work_item_tree_children_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_tree_children_spec.js new file mode 100644 index 00000000000..d4b546fd594 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_links/work_item_tree_children_spec.js @@ -0,0 +1,49 @@ +import { shallowMount } from '@vue/test-utils'; +import WorkItemTreeChildren from '~/work_items/components/work_item_links/work_item_tree_children.vue'; +import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue'; +import { childrenWorkItems } from '../../mock_data'; + +describe('WorkItemTreeChildren', () => { + let wrapper; + + const createComponent = ({ children = childrenWorkItems } = {}) => { + wrapper = shallowMount(WorkItemTreeChildren, { + propsData: { + workItemType: 'Objective', + workItemId: 'gid:://gitlab/WorkItem/1', + children, + canUpdate: true, + showLabels: true, + }, + }); + }; + + const findWorkItemLinkChildItems = () => wrapper.findAllComponents(WorkItemLinkChild); + const findWorkItemLinkChildItem = () => findWorkItemLinkChildItems().at(0); + + beforeEach(() => { + createComponent(); + }); + + it('renders all WorkItemLinkChildItems', () => { + expect(findWorkItemLinkChildItems().length).toBe(4); + }); + + it('emits childItem from WorkItemLinkChildItems on `click` event', () => { + const event = { + childItem: 'gid://gitlab/WorkItem/2', + }; + + findWorkItemLinkChildItem().vm.$emit('click', event); + + expect(wrapper.emitted('click')).toEqual([[{ childItem: 'gid://gitlab/WorkItem/2' }]]); + }); + + it('emits immediate childItem on `click` event', () => { + const event = expect.anything(); + + findWorkItemLinkChildItem().vm.$emit('click', event); + + expect(wrapper.emitted('click')).toEqual([[{ childItem: 'gid://gitlab/WorkItem/2' }]]); + }); +}); diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index f9b22e53234..4326de27dc3 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -645,6 +645,7 @@ project: - notes - snippets - hooks +- all_protected_branches - protected_branches - protected_tags - project_members diff --git a/spec/lib/gitlab/pagination/gitaly_keyset_pager_spec.rb b/spec/lib/gitlab/pagination/gitaly_keyset_pager_spec.rb index 914c1e7bb74..5dd340f69fd 100644 --- a/spec/lib/gitlab/pagination/gitaly_keyset_pager_spec.rb +++ b/spec/lib/gitlab/pagination/gitaly_keyset_pager_spec.rb @@ -129,6 +129,18 @@ RSpec.describe Gitlab::Pagination::GitalyKeysetPager, feature_category: :source_ pager.paginate(finder) end end + + context 'when the current page includes all requested elements and cursor is empty' do + let(:base_query) { { per_page: 2 } } + let(:branches) { [branch1, branch2] } + let(:next_cursor) { '' } + + it 'uses keyset pagination without link headers' do + expect(request_context).not_to receive(:header).with('Link', anything) + + pager.paginate(finder) + end + end end end diff --git a/spec/lib/sidebars/admin/menus/abuse_reports_menu_spec.rb b/spec/lib/sidebars/admin/menus/abuse_reports_menu_spec.rb index ef5b8055bec..dc75f9e0e1f 100644 --- a/spec/lib/sidebars/admin/menus/abuse_reports_menu_spec.rb +++ b/spec/lib/sidebars/admin/menus/abuse_reports_menu_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Sidebars::Admin::Menus::AbuseReportsMenu, feature_category: :navigation do it_behaves_like 'Admin menu', link: '/admin/abuse_reports', - title: _('Abuse Reports'), + title: _('Abuse reports'), icon: 'slight-frown' it_behaves_like 'Admin menu without sub menus', active_routes: { controller: :abuse_reports } diff --git a/spec/migrations/db/post_migrate/20240701182755_finalize_backfill_epic_issues_into_work_item_parent_links_spec.rb b/spec/migrations/db/post_migrate/20240701182755_finalize_backfill_epic_issues_into_work_item_parent_links_spec.rb new file mode 100644 index 00000000000..fafe50d39f9 --- /dev/null +++ b/spec/migrations/db/post_migrate/20240701182755_finalize_backfill_epic_issues_into_work_item_parent_links_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe FinalizeBackfillEpicIssuesIntoWorkItemParentLinks, feature_category: :database, migration_version: 20240701182755 do + describe '#up' do + it 'ensures the migration is completed for self-managed instances' do + # enqueue the migration + QueueBackfillEpicIssuesIntoWorkItemParentLinks.new.up + + migration = Gitlab::Database::BackgroundMigration::BatchedMigration.where( + job_class_name: 'BackfillEpicIssuesIntoWorkItemParentLinks', + table_name: 'epic_issues' + ).first + + expect(migration.status).not_to eq(6) # finalized + + migrate! + + expect(migration.reload.status).to eq(6) + QueueBackfillEpicIssuesIntoWorkItemParentLinks.new.down + end + end +end diff --git a/spec/models/import/source_user_placeholder_reference_spec.rb b/spec/models/import/source_user_placeholder_reference_spec.rb new file mode 100644 index 00000000000..e441e065f59 --- /dev/null +++ b/spec/models/import/source_user_placeholder_reference_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Import::SourceUserPlaceholderReference, feature_category: :importers do + describe 'associations' do + it { is_expected.to belong_to(:source_user).class_name('Import::SourceUser') } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:user_reference_column) } + it { is_expected.to validate_presence_of(:model) } + it { is_expected.to validate_presence_of(:namespace_id) } + it { is_expected.to validate_presence_of(:source_user_id) } + it { is_expected.to validate_numericality_of(:numeric_key).only_integer.is_greater_than(0) } + it { expect(described_class).to validate_jsonb_schema(['composite_key']) } + it { is_expected.to allow_value({ id: 1 }).for(:composite_key) } + it { is_expected.to allow_value({ id: '1' }).for(:composite_key) } + it { is_expected.to allow_value({ foo: '1', bar: 2 }).for(:composite_key) } + it { is_expected.not_to allow_value({}).for(:composite_key) } + it { is_expected.not_to allow_value({ id: 'foo' }).for(:composite_key) } + it { is_expected.not_to allow_value(1).for(:composite_key) } + + describe '#validate_numeric_or_composite_key_present' do + def validation_errors(...) + described_class.new(...).tap(&:validate) + .errors + .where(:base) + end + + it 'must have numeric_key or composite_key present', :aggregate_failures do + expect(validation_errors).to be_present + expect(validation_errors(numeric_key: 1)).to be_blank + expect(validation_errors(composite_key: { id: 1 })).to be_blank + expect(validation_errors(numeric_key: 1, composite_key: { id: 1 })).to be_present + end + end + end + + it 'is destroyed when source user is destroyed' do + reference = create(:import_source_user_placeholder_reference) + + expect { reference.source_user.destroy! }.to change { described_class.count }.by(-1) + end +end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 51e32d473a6..ce0e3ea1c6e 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -196,6 +196,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :groups_and_pr it { is_expected.to have_many(:alert_hooks_integrations).class_name('Integration') } it { is_expected.to have_many(:incident_hooks_integrations).class_name('Integration') } it { is_expected.to have_many(:relation_import_trackers).class_name('Projects::ImportExport::RelationImportTracker') } + it { is_expected.to have_many(:all_protected_branches).class_name('ProtectedBranch') } # GitLab Pages it { is_expected.to have_many(:pages_domains) } diff --git a/spec/models/protected_branch_spec.rb b/spec/models/protected_branch_spec.rb index b34e4a7bf30..06f77ea9ebb 100644 --- a/spec/models/protected_branch_spec.rb +++ b/spec/models/protected_branch_spec.rb @@ -83,34 +83,14 @@ RSpec.describe ProtectedBranch, feature_category: :source_code_management do end describe '.protected_refs' do - let_it_be(:project) { create(:project) } + let(:project) { build_stubbed(:project) } subject { described_class.protected_refs(project) } - context 'when feature flag enabled' do - before do - stub_feature_flags(group_protected_branches: true) - stub_feature_flags(allow_protected_branches_for_group: true) - end + it 'call `all_protected_branches`' do + expect(project).to receive(:all_protected_branches) - it 'call `all_protected_branches`' do - expect(project).to receive(:all_protected_branches) - - subject - end - end - - context 'when feature flag disabled' do - before do - stub_feature_flags(group_protected_branches: false) - stub_feature_flags(allow_protected_branches_for_group: false) - end - - it 'call `protected_branches`' do - expect(project).to receive(:protected_branches) - - subject - end + subject end end diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb index c1fe5c6d0a9..45ab91c74e8 100644 --- a/spec/requests/api/branches_spec.rb +++ b/spec/requests/api/branches_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe API::Branches, feature_category: :source_code_management do let_it_be(:user) { create(:user) } - let(:project) { create(:project, :repository, creator: user, path: 'my.project', create_branch: 'ends-with.txt') } + let(:project) { create(:project, :in_group, :repository, creator: user, path: 'my.project', create_branch: 'ends-with.txt') } let(:guest) { create(:user, guest_of: project) } let(:branch_name) { 'feature' } let(:branch_sha) { '0b4bc9a49b562e85de7cc9e834518ea6828729b9' } @@ -295,10 +295,79 @@ RSpec.describe API::Branches, feature_category: :source_code_management do new_branch_name = 'protected-branch' ::Branches::CreateService.new(project, current_user).execute(new_branch_name, 'master') create(:protected_branch, name: new_branch_name, project: project) + create(:protected_branch, name: new_branch_name, project: nil, group: project.group) expect do get api(route, current_user), params: { per_page: 100 } - end.not_to exceed_query_limit(control).with_threshold(1) + end.not_to exceed_query_limit(control) + end + end + + context 'with group protected branches' do + subject(:request) { get api(route, user) } + + let!(:group_protected_branch) do + create(:protected_branch, + *access_levels, + project: nil, + group: project.group, + name: '*' + ) + end + + context 'maintainers allowed to push and merge' do + let(:access_levels) { [] } + + it 'responds with correct attributes related to push and merge' do + request + + expect(json_response.dig(0, 'developers_can_merge')).to be_falsey + expect(json_response.dig(0, 'developers_can_push')).to be_falsey + expect(json_response.dig(0, 'can_push')).to be_truthy + end + + context 'and there is a more permissive project level protected branch' do + let!(:project_level_protected_branch) do + create(:protected_branch, + :developers_can_merge, + :developers_can_push, + project: project, + name: '*' + ) + end + + it 'responds with correct attributes related to push and merge' do + request + + expect(json_response.dig(0, 'developers_can_merge')).to be_truthy + expect(json_response.dig(0, 'developers_can_push')).to be_truthy + expect(json_response.dig(0, 'can_push')).to be_truthy + end + end + end + + context 'when developers can push and merge' do + let(:access_levels) { %i[developers_can_merge developers_can_push] } + + it 'responds with correct attributes related to push and merge' do + request + + expect(json_response.dig(0, 'developers_can_merge')).to be_truthy + expect(json_response.dig(0, 'developers_can_push')).to be_truthy + expect(json_response.dig(0, 'can_push')).to be_truthy + end + end + + context 'when no one can push and merge' do + let(:access_levels) { %i[no_one_can_merge no_one_can_push] } + + it 'responds with correct attributes related to push and merge' do + request + + expect(json_response.dig(0, 'developers_can_merge')).to be_falsey + expect(json_response.dig(0, 'developers_can_push')).to be_falsey + expect(json_response.dig(0, 'can_push')).to be_falsey + end end end diff --git a/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb index f9a34f04237..c4540c8ca2f 100644 --- a/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb +++ b/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb @@ -247,6 +247,25 @@ RSpec.shared_examples 'User views a wiki page' do end end + context 'when a page has headings' do + before do + wiki_page.update(content: "# Heading 1\n\n## Heading 1.1\n\n### Heading 1.1.1\n\n# Heading 2") # rubocop:disable Rails/SaveBang -- not an ActiveRecord + end + + it 'displays the table of contents for the page' do + visit(wiki_page_path(wiki, wiki_page)) + + within '.js-wiki-toc' do + expect(page).to have_content('On this page') + + expect(page).to have_content('Heading 1') + expect(page).to have_content('Heading 1.1') + expect(page).to have_content('Heading 1.1.1') + expect(page).to have_content('Heading 2') + end + end + end + context 'when page has invalid content encoding' do let(:content) { (+'whatever').force_encoding('ISO-8859-1') } diff --git a/workhorse/cmd/gitlab-workhorse/channel_test.go b/workhorse/cmd/gitlab-workhorse/channel_test.go index 6e8ad945fd2..11b414cc526 100644 --- a/workhorse/cmd/gitlab-workhorse/channel_test.go +++ b/workhorse/cmd/gitlab-workhorse/channel_test.go @@ -174,7 +174,7 @@ func webSocketHandler(upgrader *websocket.Upgrader, connCh chan connWithReq) htt func channelOkBody(remote *httptest.Server, header http.Header, subprotocols ...string) *api.Response { out := &api.Response{ Channel: &api.ChannelSettings{ - WsURL: websocketURL(remote.URL), + Url: websocketURL(remote.URL), Header: header, Subprotocols: subprotocols, MaxSessionTime: 0, diff --git a/workhorse/internal/api/channel_settings.go b/workhorse/internal/api/channel_settings.go index 6e8d2f13b07..779d2ad7e69 100644 --- a/workhorse/internal/api/channel_settings.go +++ b/workhorse/internal/api/channel_settings.go @@ -19,7 +19,7 @@ type ChannelSettings struct { Subprotocols []string // The websocket URL to connect to. - WsURL string + Url string //nolint:revive,stylecheck // when JSON decoding, the value is provided via 'url' // Any headers (e.g., Authorization) to send with the websocket request Header http.Header @@ -35,7 +35,7 @@ type ChannelSettings struct { // URL parses the websocket URL in the ChannelSettings and returns a *url.URL. func (t *ChannelSettings) URL() (*url.URL, error) { - return url.Parse(t.WsURL) + return url.Parse(t.Url) } // Dialer returns a websocket Dialer configured with the settings from ChannelSettings. @@ -72,7 +72,7 @@ func (t *ChannelSettings) Clone() *ChannelSettings { // Dial establishes a websocket connection using the settings from ChannelSettings. // It returns a websocket connection, an HTTP response, and an error if any. func (t *ChannelSettings) Dial() (*websocket.Conn, *http.Response, error) { - return t.Dialer().Dial(t.WsURL, t.Header) + return t.Dialer().Dial(t.Url, t.Header) } // Validate checks if the ChannelSettings instance is valid. @@ -133,7 +133,7 @@ func (t *ChannelSettings) IsEqual(other *ChannelSettings) bool { } } - return t.WsURL == other.WsURL && + return t.Url == other.Url && t.CAPem == other.CAPem && t.MaxSessionTime == other.MaxSessionTime } diff --git a/workhorse/internal/api/channel_settings_test.go b/workhorse/internal/api/channel_settings_test.go index d33d78222d2..1735a8b6e3d 100644 --- a/workhorse/internal/api/channel_settings_test.go +++ b/workhorse/internal/api/channel_settings_test.go @@ -9,7 +9,7 @@ import ( func channel(url string, subprotocols ...string) *ChannelSettings { return &ChannelSettings{ - WsURL: url, + Url: url, Subprotocols: subprotocols, MaxSessionTime: 0, } @@ -146,7 +146,7 @@ func TestIsEqual(t *testing.T) { {chann, chann, true}, {chann.Clone(), chann.Clone(), true}, {chann, channel("foo:"), false}, - {chann, channel(chann.WsURL), false}, + {chann, channel(chann.Url), false}, {header(chann), header(chann), true}, {channHeader2, channHeader2, true}, {channHeader3, channHeader3, true}, diff --git a/workhorse/internal/channel/auth_checker_test.go b/workhorse/internal/channel/auth_checker_test.go index 36a45b07c1e..b522cf5da1f 100644 --- a/workhorse/internal/channel/auth_checker_test.go +++ b/workhorse/internal/channel/auth_checker_test.go @@ -19,7 +19,7 @@ func checkerSeries(values ...*api.ChannelSettings) AuthCheckerFunc { } func TestAuthCheckerStopsWhenAuthFails(t *testing.T) { - template := &api.ChannelSettings{WsURL: "ws://example.com"} + template := &api.ChannelSettings{Url: "ws://example.com"} stopCh := make(chan error) series := checkerSeries(template, template, template) ac := NewAuthChecker(series, template, stopCh) @@ -35,9 +35,9 @@ func TestAuthCheckerStopsWhenAuthFails(t *testing.T) { } func TestAuthCheckerStopsWhenAuthChanges(t *testing.T) { - template := &api.ChannelSettings{WsURL: "ws://example.com"} + template := &api.ChannelSettings{Url: "ws://example.com"} changed := template.Clone() - changed.WsURL = "wss://example.com" + changed.Url = "wss://example.com" stopCh := make(chan error) series := checkerSeries(template, changed, template) ac := NewAuthChecker(series, template, stopCh)