From 8432be20de0a29f4dde4980efe37d013c9e90034 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Mon, 17 Jan 2022 15:16:12 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- .rubocop_todo/graphql/field_definitions.yml | 3 - .rubocop_todo/rails/save_bang.yml | 15 - GITALY_SERVER_VERSION | 2 +- .../projects/planning_hierarchy/index.js | 3 + .../permissions/components/settings_panel.vue | 41 +- .../projects/shared/permissions/index.js | 31 +- .../javascripts/work_items/constants.js | 49 + .../work_items_hierarchy/components/app.vue | 96 ++ .../components/hierarchy.vue | 119 ++ .../work_items_hierarchy/constants.js | 12 + .../work_items_hierarchy/hierarchy_util.js | 10 + .../work_items_hierarchy/static_response.js | 142 ++ .../work_items_hierarchy_bundle.js | 26 + .../stylesheets/_page_specific_files.scss | 1 + app/assets/stylesheets/pages/hierarchy.scss | 15 + .../stylesheets/startup/startup-signin.scss | 3 + .../concerns/work_items_hierarchy.rb | 15 + app/controllers/projects_controller.rb | 2 + app/graphql/types/project_type.rb | 51 +- app/graphql/types/projects/topic_type.rb | 3 +- app/graphql/types/release_type.rb | 11 +- app/helpers/projects_helper.rb | 10 +- app/models/project_ci_cd_setting.rb | 2 +- app/policies/project_policy.rb | 2 + app/views/devise/shared/_signup_box.html.haml | 2 +- .../personal_access_tokens/index.html.haml | 61 +- app/views/projects/edit.html.haml | 7 +- app/views/shared/planning_hierarchy.html.haml | 5 + app/views/shared/web_hooks/_hook.html.haml | 15 +- ...ss_tokens.yml => work_items_hierarchy.yml} | 12 +- config/routes/project.rb | 1 + data/deprecations/14-7-pseudonymizer.yml | 4 +- ...-7-sidekiq-metrics-health-check-donfig.yml | 30 + ...xes_for_primary_email_cleanup_migration.rb | 28 + ...imary_email_to_emails_if_user_confirmed.rb | 59 + ...ary_indexes_for_primary_email_migration.rb | 28 + db/schema_migrations/20211206161271 | 1 + db/schema_migrations/20211206162601 | 1 + db/schema_migrations/20211220064757 | 1 + doc/administration/logs.md | 60 +- .../operations/fast_ssh_key_lookup.md | 14 +- doc/ci/pipelines/settings.md | 7 +- doc/update/deprecations.md | 26 +- doc/update/index.md | 25 + .../dependency_scanning/index.md | 3 + .../iac_scanning/index.md | 2 + .../secret_detection/index.md | 8 +- ...view-project-work-item-hierarchy_v14_7.png | Bin 0 -> 38783 bytes doc/user/group/planning_hierarchy/index.md | 14 + .../Jobs/Secret-Detection.gitlab-ci.yml | 2 + lib/sidebars/concerns/work_item_hierarchy.rb | 27 + .../menus/project_information_menu.rb | 3 + locale/gitlab.pot | 54 +- .../visibility_features_permissions.rb | 5 +- .../merge_merge_request_from_fork_spec.rb | 6 +- .../concerns/work_items_hierarchy_spec.rb | 39 + spec/features/profile_spec.rb | 63 +- .../profiles/personal_access_tokens_spec.rb | 11 +- .../projects/features_visibility_spec.rb | 4 +- .../settings/project_settings_spec.rb | 4 +- ...er_manages_merge_requests_settings_spec.rb | 6 +- .../user_changes_project_visibility_spec.rb | 88 +- .../components/settings_panel_spec.js | 42 + .../components/__snapshots__/app_spec.js.snap | 1197 +++++++++++++++++ .../components/app_spec.js | 78 ++ .../components/hierarchy_spec.js | 118 ++ .../hierarchy_util_spec.js | 16 + .../concerns/work_item_hierarchy_spec.rb | 36 + .../menus/project_information_menu_spec.rb | 20 + ..._email_to_emails_if_user_confirmed_spec.rb | 48 + .../navbar_structure_context.rb | 1 + .../policies/project_policy_shared_context.rb | 2 +- tooling/bin/find_changes | 2 +- 73 files changed, 2670 insertions(+), 280 deletions(-) create mode 100644 app/assets/javascripts/pages/projects/planning_hierarchy/index.js create mode 100644 app/assets/javascripts/work_items_hierarchy/components/app.vue create mode 100644 app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue create mode 100644 app/assets/javascripts/work_items_hierarchy/constants.js create mode 100644 app/assets/javascripts/work_items_hierarchy/hierarchy_util.js create mode 100644 app/assets/javascripts/work_items_hierarchy/static_response.js create mode 100644 app/assets/javascripts/work_items_hierarchy/work_items_hierarchy_bundle.js create mode 100644 app/assets/stylesheets/pages/hierarchy.scss create mode 100644 app/controllers/concerns/work_items_hierarchy.rb create mode 100644 app/views/shared/planning_hierarchy.html.haml rename config/feature_flags/development/{hide_access_tokens.yml => work_items_hierarchy.yml} (55%) create mode 100644 data/deprecations/14-7-sidekiq-metrics-health-check-donfig.yml create mode 100644 db/post_migrate/20211206161271_add_indexes_for_primary_email_cleanup_migration.rb create mode 100644 db/post_migrate/20211206162601_cleanup_after_add_primary_email_to_emails_if_user_confirmed.rb create mode 100644 db/post_migrate/20211220064757_drop_temporary_indexes_for_primary_email_migration.rb create mode 100644 db/schema_migrations/20211206161271 create mode 100644 db/schema_migrations/20211206162601 create mode 100644 db/schema_migrations/20211220064757 create mode 100644 doc/user/group/planning_hierarchy/img/view-project-work-item-hierarchy_v14_7.png create mode 100644 lib/sidebars/concerns/work_item_hierarchy.rb create mode 100644 spec/controllers/concerns/work_items_hierarchy_spec.rb create mode 100644 spec/frontend/work_items_hierarchy/components/__snapshots__/app_spec.js.snap create mode 100644 spec/frontend/work_items_hierarchy/components/app_spec.js create mode 100644 spec/frontend/work_items_hierarchy/components/hierarchy_spec.js create mode 100644 spec/frontend/work_items_hierarchy/hierarchy_util_spec.js create mode 100644 spec/lib/sidebars/concerns/work_item_hierarchy_spec.rb create mode 100644 spec/migrations/cleanup_after_add_primary_email_to_emails_if_user_confirmed_spec.rb diff --git a/.rubocop_todo/graphql/field_definitions.yml b/.rubocop_todo/graphql/field_definitions.yml index c6ca8674264..35ed870fa41 100644 --- a/.rubocop_todo/graphql/field_definitions.yml +++ b/.rubocop_todo/graphql/field_definitions.yml @@ -5,9 +5,6 @@ GraphQL/FieldDefinitions: - app/graphql/types/group_type.rb - app/graphql/types/issue_type.rb - app/graphql/types/label_type.rb - - app/graphql/types/project_type.rb - - app/graphql/types/projects/topic_type.rb - - app/graphql/types/release_type.rb - ee/app/graphql/types/ci/code_quality_degradation_type.rb - ee/app/graphql/types/epic_type.rb - ee/app/graphql/types/group_release_stats_type.rb diff --git a/.rubocop_todo/rails/save_bang.yml b/.rubocop_todo/rails/save_bang.yml index 8f2be251dc5..c6345b5eda1 100644 --- a/.rubocop_todo/rails/save_bang.yml +++ b/.rubocop_todo/rails/save_bang.yml @@ -18,21 +18,6 @@ Rails/SaveBang: - ee/spec/models/visible_approvable_spec.rb - ee/spec/models/vulnerabilities/feedback_spec.rb - ee/spec/models/vulnerabilities/issue_link_spec.rb - - ee/spec/services/geo/blob_verification_secondary_service_spec.rb - - ee/spec/services/geo/files_expire_service_spec.rb - - ee/spec/services/geo/metrics_update_service_spec.rb - - ee/spec/services/geo/registry_consistency_service_spec.rb - - ee/spec/services/geo/repository_verification_secondary_service_spec.rb - - ee/spec/services/groups/autocomplete_service_spec.rb - - ee/spec/services/ldap_group_reset_service_spec.rb - - ee/spec/services/lfs/unlock_file_service_spec.rb - - ee/spec/services/merge_trains/refresh_merge_request_service_spec.rb - - ee/spec/services/quick_actions/interpret_service_spec.rb - - ee/spec/services/slash_commands/global_slack_handler_spec.rb - - ee/spec/services/start_pull_mirroring_service_spec.rb - - ee/spec/services/status_page/trigger_publish_service_spec.rb - - ee/spec/services/todo_service_spec.rb - - ee/spec/services/vulnerability_feedback/create_service_spec.rb - spec/lib/backup/manager_spec.rb - spec/lib/gitlab/alerting/alert_spec.rb - spec/lib/gitlab/analytics/cycle_analytics/records_fetcher_spec.rb diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 76e52a1d82b..1e1faedc414 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -501a7a9b19eb80ec039caeb8019cab7a8cfcbb44 +7ce0d18ad44686865aa0dbf5f1b47d9cc05988be diff --git a/app/assets/javascripts/pages/projects/planning_hierarchy/index.js b/app/assets/javascripts/pages/projects/planning_hierarchy/index.js new file mode 100644 index 00000000000..d5dfe2d5f37 --- /dev/null +++ b/app/assets/javascripts/pages/projects/planning_hierarchy/index.js @@ -0,0 +1,3 @@ +import { initWorkItemsHierarchy } from '~/work_items_hierarchy/work_items_hierarchy_bundle'; + +initWorkItemsHierarchy(); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index 384ee1f5034..9393ec68586 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -1,6 +1,6 @@ + + diff --git a/app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue b/app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue new file mode 100644 index 00000000000..9b81218b6e4 --- /dev/null +++ b/app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue @@ -0,0 +1,119 @@ + + diff --git a/app/assets/javascripts/work_items_hierarchy/constants.js b/app/assets/javascripts/work_items_hierarchy/constants.js new file mode 100644 index 00000000000..f001f556c0e --- /dev/null +++ b/app/assets/javascripts/work_items_hierarchy/constants.js @@ -0,0 +1,12 @@ +export const WORK_ITEMS_SURVEY_COOKIE_NAME = 'hide_work_items_hierarchy_survey'; + +/** + * Hard-coded strings since we're rendering hierarchy + * items from mock responses. Remove this when we + * have a real hierarchy endpoint. + */ +export const LICENSE_PLAN = { + FREE: 'free', + PREMIUM: 'premium', + ULTIMATE: 'ultimate', +}; diff --git a/app/assets/javascripts/work_items_hierarchy/hierarchy_util.js b/app/assets/javascripts/work_items_hierarchy/hierarchy_util.js new file mode 100644 index 00000000000..61d93acdb91 --- /dev/null +++ b/app/assets/javascripts/work_items_hierarchy/hierarchy_util.js @@ -0,0 +1,10 @@ +import { LICENSE_PLAN } from './constants'; + +export function inferLicensePlan({ hasSubEpics, hasEpics }) { + if (hasSubEpics) { + return LICENSE_PLAN.ULTIMATE; + } else if (hasEpics) { + return LICENSE_PLAN.PREMIUM; + } + return LICENSE_PLAN.FREE; +} diff --git a/app/assets/javascripts/work_items_hierarchy/static_response.js b/app/assets/javascripts/work_items_hierarchy/static_response.js new file mode 100644 index 00000000000..d1e2e486082 --- /dev/null +++ b/app/assets/javascripts/work_items_hierarchy/static_response.js @@ -0,0 +1,142 @@ +const FREE_TIER = 'free'; +const ULTIMATE_TIER = 'ultimate'; +const PREMIUM_TIER = 'premium'; + +const RESPONSE = { + [FREE_TIER]: [ + { + id: '1', + type: 'ISSUE', + available: true, + license: null, + nestedTypes: null, + }, + { + id: '2', + type: 'TASK', + available: true, + license: null, + nestedTypes: null, + }, + { + id: '3', + type: 'INCIDENT', + available: true, + license: null, + nestedTypes: null, + }, + { + id: '4', + type: 'EPIC', + available: false, + license: 'Premium', // eslint-disable-line @gitlab/require-i18n-strings + nestedTypes: null, + }, + { + id: '5', + type: 'SUB_EPIC', + available: false, + license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings + nestedTypes: null, + }, + { + id: '6', + type: 'REQUIREMENT', + available: false, + license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings + nestedTypes: null, + }, + { + id: '7', + type: 'TEST_CASE', + available: false, + license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings + nestedTypes: null, + }, + ], + + [PREMIUM_TIER]: [ + { + id: '1', + type: 'EPIC', + available: true, + license: null, + nestedTypes: ['ISSUE'], + }, + { + id: '2', + type: 'TASK', + available: true, + license: null, + nestedTypes: null, + }, + { + id: '3', + type: 'INCIDENT', + available: true, + license: null, + nestedTypes: null, + }, + { + id: '5', + type: 'SUB_EPIC', + available: false, + license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings + nestedTypes: null, + }, + { + id: '6', + type: 'REQUIREMENT', + available: false, + license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings + nestedTypes: null, + }, + { + id: '7', + type: 'TEST_CASE', + available: false, + license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings + nestedTypes: null, + }, + ], + + [ULTIMATE_TIER]: [ + { + id: '1', + type: 'EPIC', + available: true, + license: null, + nestedTypes: ['SUB_EPIC', 'ISSUE'], + }, + { + id: '2', + type: 'TASK', + available: true, + license: null, + nestedTypes: null, + }, + { + id: '3', + type: 'INCIDENT', + available: true, + license: null, + nestedTypes: null, + }, + { + id: '6', + type: 'REQUIREMENT', + available: true, + license: null, + nestedTypes: null, + }, + { + id: '7', + type: 'TEST_CASE', + available: true, + license: null, + nestedTypes: null, + }, + ], +}; + +export default RESPONSE; diff --git a/app/assets/javascripts/work_items_hierarchy/work_items_hierarchy_bundle.js b/app/assets/javascripts/work_items_hierarchy/work_items_hierarchy_bundle.js new file mode 100644 index 00000000000..2258c725301 --- /dev/null +++ b/app/assets/javascripts/work_items_hierarchy/work_items_hierarchy_bundle.js @@ -0,0 +1,26 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import App from './components/app.vue'; +import { inferLicensePlan } from './hierarchy_util'; + +export const initWorkItemsHierarchy = () => { + const el = document.querySelector('#js-work-items-hierarchy'); + + const { illustrationPath, hasEpics, hasSubEpics } = el.dataset; + + const licensePlan = inferLicensePlan({ + hasEpics: parseBoolean(hasEpics), + hasSubEpics: parseBoolean(hasSubEpics), + }); + + return new Vue({ + el, + provide: { + illustrationPath, + licensePlan, + }, + render(createElement) { + return createElement(App); + }, + }); +}; diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index 8f3b5b3b7cc..072b78305a9 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -32,3 +32,4 @@ @import './pages/storage_quota'; @import './pages/tree'; @import './pages/users'; +@import './pages/hierarchy'; diff --git a/app/assets/stylesheets/pages/hierarchy.scss b/app/assets/stylesheets/pages/hierarchy.scss new file mode 100644 index 00000000000..0812e4cc41e --- /dev/null +++ b/app/assets/stylesheets/pages/hierarchy.scss @@ -0,0 +1,15 @@ +.hierarchy-rounded-arrow-tail { + position: absolute; + top: 4px; + left: 5px; + height: calc(100% - 20px); +} + +.hierarchy-icon-wrapper { + height: $default-icon-size; + width: $default-icon-size; +} + +.hierarchy-rounded-arrow { + transform: scale(1, -1) rotate(90deg); +} diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss index e616996bd14..3ed257caf60 100644 --- a/app/assets/stylesheets/startup/startup-signin.scss +++ b/app/assets/stylesheets/startup/startup-signin.scss @@ -772,6 +772,9 @@ svg { .gl-mt-2 { margin-top: 0.25rem; } +.gl-mt-5 { + margin-top: 1rem; +} .gl-mb-3 { margin-bottom: 0.5rem; } diff --git a/app/controllers/concerns/work_items_hierarchy.rb b/app/controllers/concerns/work_items_hierarchy.rb new file mode 100644 index 00000000000..6008256408c --- /dev/null +++ b/app/controllers/concerns/work_items_hierarchy.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module WorkItemsHierarchy + extend ActiveSupport::Concern + + # rubocop:disable Gitlab/ModuleWithInstanceVariables + def planning_hierarchy + return render_404 unless Feature.enabled?(:work_items_hierarchy, @project, default_enabled: :yaml) + + render 'shared/planning_hierarchy' + end + # rubocop:enable Gitlab/ModuleWithInstanceVariables +end + +WorkItemsHierarchy.prepend_mod_with('WorkItemsHierarchy') diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 04dde5ef7b2..27483d455b1 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -9,6 +9,7 @@ class ProjectsController < Projects::ApplicationController include RecordUserLastActivity include ImportUrlParams include FiltersEvents + include WorkItemsHierarchy prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } @@ -52,6 +53,7 @@ class ProjectsController < Projects::ApplicationController feature_category :team_planning, [:preview_markdown, :new_issuable_address] feature_category :importers, [:export, :remove_export, :generate_new_export, :download_export] feature_category :code_review, [:unfoldered_environment_names] + feature_category :portfolio_management, [:planning_hierarchy] urgency :low, [:refs] urgency :high, [:unfoldered_environment_names] diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index c988a232a87..f4067552f55 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -27,7 +27,6 @@ module Types field :description, GraphQL::Types::String, null: true, description: 'Short description of the project.' - markdown_field :description_html, null: true field :tag_list, GraphQL::Types::String, null: true, deprecated: { reason: 'Use `topics`', milestone: '13.12' }, @@ -75,21 +74,6 @@ module Types field :avatar_url, GraphQL::Types::String, null: true, calls_gitaly: true, description: 'URL to avatar image file of the project.' - { - issues: "Issues are", - merge_requests: "Merge Requests are", - wiki: 'Wikis are', - snippets: 'Snippets are', - container_registry: 'Container Registry is' - }.each do |feature, name_string| - field "#{feature}_enabled", GraphQL::Types::Boolean, null: true, - description: "Indicates if #{name_string} enabled for the current user" - - define_method "#{feature}_enabled" do - object.feature_available?(feature, context[:current_user]) - end - end - field :jobs_enabled, GraphQL::Types::Boolean, null: true, description: 'Indicates if CI/CD pipeline jobs are enabled for the current user.' @@ -391,15 +375,6 @@ module Types null: true, description: 'Template used to create squash commit message in merge requests.' - def label(title:) - BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args| - LabelsFinder - .new(current_user, project: args[:key], title: titles) - .execute - .each { |label| loader.call(label.title, label) } - end - end - field :labels, Types::LabelType.connection_type, null: true, @@ -411,6 +386,32 @@ module Types description: 'Work item types available to the project.', feature_flag: :work_items + def label(title:) + BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args| + LabelsFinder + .new(current_user, project: args[:key], title: titles) + .execute + .each { |label| loader.call(label.title, label) } + end + end + + { + issues: "Issues are", + merge_requests: "Merge Requests are", + wiki: 'Wikis are', + snippets: 'Snippets are', + container_registry: 'Container Registry is' + }.each do |feature, name_string| + field "#{feature}_enabled", GraphQL::Types::Boolean, null: true, + description: "Indicates if #{name_string} enabled for the current user" + + define_method "#{feature}_enabled" do + object.feature_available?(feature, context[:current_user]) + end + end + + markdown_field :description_html, null: true + def avatar_url object.avatar_url(only_path: false) end diff --git a/app/graphql/types/projects/topic_type.rb b/app/graphql/types/projects/topic_type.rb index 79ab69e794b..c579f2f2b9d 100644 --- a/app/graphql/types/projects/topic_type.rb +++ b/app/graphql/types/projects/topic_type.rb @@ -14,11 +14,12 @@ module Types field :description, GraphQL::Types::String, null: true, description: 'Description of the topic.' - markdown_field :description_html, null: true field :avatar_url, GraphQL::Types::String, null: true, description: 'URL to avatar image file of the topic.' + markdown_field :description_html, null: true + def avatar_url object.avatar_url(only_path: false) end diff --git a/app/graphql/types/release_type.rb b/app/graphql/types/release_type.rb index fcc9ec49252..fbc3779ea9b 100644 --- a/app/graphql/types/release_type.rb +++ b/app/graphql/types/release_type.rb @@ -20,7 +20,6 @@ module Types authorize: :download_code field :description, GraphQL::Types::String, null: true, description: 'Description (also known as "release notes") of the release.' - markdown_field :description_html, null: true field :name, GraphQL::Types::String, null: true, description: 'Name of the release.' field :created_at, Types::TimeType, null: true, @@ -42,14 +41,16 @@ module Types field :author, Types::UserType, null: true, description: 'User that created the release.' - def author - Gitlab::Graphql::Loaders::BatchModelLoader.new(User, release.author_id).find - end - field :commit, Types::CommitType, null: true, complexity: 10, calls_gitaly: true, description: 'Commit associated with the release.' + markdown_field :description_html, null: true + + def author + Gitlab::Graphql::Loaders::BatchModelLoader.new(User, release.author_id).find + end + def commit return if release.sha.nil? diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index de696c2ec87..084c962d34c 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -672,18 +672,16 @@ module ProjectsHelper html_escape(message) % { strong_start: strong_start, strong_end: strong_end, project_name: project.name, group_name: project.group ? project.group.name : nil } end - def visibility_confirm_modal_data(project, remove_form_id = nil) + def visibility_confirm_modal_data(project, target_form_id = nil) { - remove_form_id: remove_form_id, - qa_selector: 'visibility_features_permissions_save_button', - button_text: _('Save changes'), + target_form_id: target_form_id, button_testid: 'reduce-project-visibility-button', - button_variant: 'confirm', confirm_button_text: _('Reduce project visibility'), confirm_danger_message: confirm_reduce_visibility_message(project), phrase: project.full_path, additional_information: _('Note: current forks will keep their visibility level.'), - html_confirmation_message: true + html_confirmation_message: true.to_s, + show_visibility_confirm_modal: show_visibility_confirm_modal?(project).to_s } end diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb index c0c2ea42d46..c8a0b247b59 100644 --- a/app/models/project_ci_cd_setting.rb +++ b/app/models/project_ci_cd_setting.rb @@ -3,7 +3,7 @@ class ProjectCiCdSetting < ApplicationRecord belongs_to :project, inverse_of: :ci_cd_settings - DEFAULT_GIT_DEPTH = 50 + DEFAULT_GIT_DEPTH = 20 before_create :set_default_git_depth diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 55f43cd9f7b..d89a449bdd5 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -240,6 +240,7 @@ class ProjectPolicy < BasePolicy enable :read_wiki enable :read_issue enable :read_label + enable :read_work_items_hierarchy enable :read_milestone enable :read_snippet enable :read_project_member @@ -572,6 +573,7 @@ class ProjectPolicy < BasePolicy enable :read_issue_board_list enable :read_wiki enable :read_label + enable :read_work_items_hierarchy enable :read_milestone enable :read_snippet enable :read_project_member diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index 15143684b8b..982171b9e34 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -62,7 +62,7 @@ %div - if show_recaptcha_sign_up? = recaptcha_tags nonce: content_security_policy_nonce - .submit-container + .submit-container.gl-mt-5 = f.submit button_text, class: 'btn gl-button btn-confirm gl-display-block gl-w-full', data: { qa_selector: 'new_user_register_button' } = render 'devise/shared/terms_of_service_notice', button_text: button_text - if show_omniauth_providers && omniauth_providers_placement == :bottom diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index a8275576327..887d07f7a20 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -32,64 +32,5 @@ type_plural: type_plural, active_tokens: @active_personal_access_tokens, revoke_route_helper: ->(token) { revoke_profile_personal_access_token_path(token) } -- if Feature.enabled?(:hide_access_tokens, default_enabled: :yaml) - #js-tokens-app{ data: { tokens_data: tokens_app_data } } -- else - - unless Gitlab::CurrentSettings.disable_feed_token - .col-lg-12 - %hr - .row.gl-mt-3.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 - = s_('AccessTokens|Feed token') - %p - = s_('AccessTokens|Your feed token authenticates you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar. It is visible in those feed URLs.') - %p - = s_('AccessTokens|It cannot be used to access any other data.') - .col-lg-8.feed-token-reset - = label_tag :feed_token, s_('AccessTokens|Feed token'), class: 'label-bold' - = text_field_tag :feed_token, current_user.feed_token, class: 'form-control gl-form-input js-select-on-focus', readonly: true - %p.form-text.text-muted - - reset_link = link_to s_('AccessTokens|reset this token'), [:reset, :feed_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.'), testid: :reset_feed_token_link } - - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can read activity and issue RSS feeds or your calendar feed as if they were you. If that happens, %{link_reset_it}.') % { link_reset_it: reset_link } - = reset_message.html_safe - - if incoming_email_token_enabled? - .col-lg-12 - %hr - .row.gl-mt-3.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 - = s_('AccessTokens|Incoming email token') - %p - = s_('AccessTokens|Your incoming email token authenticates you when you create a new issue by email, and is included in your personal project-specific email addresses.') - %p - = s_('AccessTokens|It cannot be used to access any other data.') - .col-lg-8.incoming-email-token-reset - = label_tag :incoming_email_token, s_('AccessTokens|Incoming email token'), class: 'label-bold' - = text_field_tag :incoming_email_token, current_user.incoming_email_token, class: 'form-control gl-form-input js-select-on-focus', readonly: true - %p.form-text.text-muted - - reset_link = link_to s_('AccessTokens|reset this token'), [:reset, :incoming_email_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any issue email addresses currently in use will stop working.'), testid: :reset_email_token_link } - - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can create issues as if they were you. If that happens, %{link_reset_it}.') % { link_reset_it: reset_link } - = reset_message.html_safe - - - if static_objects_external_storage_enabled? - .col-lg-12 - %hr - .row.gl-mt-3.js-search-settings-section - .col-lg-4 - %h4.gl-mt-0 - = s_('AccessTokens|Static object token') - %p - = s_('AccessTokens|Your static object token authenticates you when repository static objects (such as archives or blobs) are served from an external storage.') - %p - = s_('AccessTokens|It cannot be used to access any other data.') - .col-lg-8 - = label_tag :static_object_token, s_('AccessTokens|Static object token'), class: "label-bold" - = text_field_tag :static_object_token, current_user.static_object_token, class: 'form-control gl-form-input', readonly: true, onclick: 'this.select()' - %p.form-text.text-muted - - reset_link = url_for [:reset, :static_object_token, :profile] - - reset_link_start = ''.html_safe % { confirm: s_('AccessTokens|Are you sure?'), url: reset_link } - - reset_link_end = ''.html_safe - - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can access repository static objects as if they were you. If that ever happens, %{reset_link_start}reset this token%{reset_link_end}.') % { reset_link_start: reset_link_start, reset_link_end: reset_link_end } - = reset_message.html_safe +#js-tokens-app{ data: { tokens_data: tokens_app_data } } diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 1cb935307af..aa9a3ea61f7 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -2,7 +2,7 @@ - page_title _("General") - @content_class = "limit-container-width" unless fluid_layout - expanded = expanded_by_default? -- remove_visibility_form_id = 'reduce-visibility-form' +- reduce_visibility_form_id = 'reduce-visibility-form' %section.settings.general-settings.no-animate.expanded#js-general-settings .settings-header @@ -18,11 +18,10 @@ %p= _('Choose visibility level, enable/disable project features and their permissions, disable email notifications, and show default award emoji.') .settings-content - = form_for @project, html: { multipart: true, class: "sharing-permissions-form", id: remove_visibility_form_id }, authenticity_token: true do |f| + = form_for @project, html: { multipart: true, class: "sharing-permissions-form", id: reduce_visibility_form_id }, authenticity_token: true do |f| %input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' } %template.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data(@project).to_json.html_safe - .js-project-permissions-form - = f.submit _('Save changes'), class: "btn gl-button btn-confirm #{('js-confirm-danger' if show_visibility_confirm_modal?(@project))}", data: visibility_confirm_modal_data(@project, remove_visibility_form_id) + .js-project-permissions-form{ data: visibility_confirm_modal_data(@project, reduce_visibility_form_id) } %section.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)], data: { qa_selector: 'merge_request_settings_content' } } .settings-header diff --git a/app/views/shared/planning_hierarchy.html.haml b/app/views/shared/planning_hierarchy.html.haml new file mode 100644 index 00000000000..d67ecc6ee48 --- /dev/null +++ b/app/views/shared/planning_hierarchy.html.haml @@ -0,0 +1,5 @@ +- page_title _("Planning hierarchy") +- has_sub_epics = Gitlab.ee? && @project&.feature_available?(:subepics) +- has_epics = Gitlab.ee? && @project&.feature_available?(:epics) + +#js-work-items-hierarchy{ data: { has_sub_epics: has_sub_epics.to_s, has_epics: has_epics.to_s, illustration_path: image_path('illustrations/rocket-launch-md.svg') } } diff --git a/app/views/shared/web_hooks/_hook.html.haml b/app/views/shared/web_hooks/_hook.html.haml index 45baa7e2184..c5a03ef4dc1 100644 --- a/app/views/shared/web_hooks/_hook.html.haml +++ b/app/views/shared/web_hooks/_hook.html.haml @@ -1,22 +1,23 @@ +- sslStatus = hook.enable_ssl_verification ? _('enabled') : _('disabled') +- sslBadgeText = _('SSL Verification:') + ' ' + sslStatus + %li .row .col-md-8.col-lg-7 %strong.light-header = hook.url - if hook.rate_limited? - %span.gl-badge.badge-danger.badge-pill.sm= _('Disabled') + = gl_badge_tag(_('Disabled'), variant: :danger, size: :sm) - elsif hook.permanently_disabled? - %span.gl-badge.badge-danger.badge-pill.sm= s_('Webhooks|Failed to connect') + = gl_badge_tag(s_('Webhooks|Failed to connect'), variant: :danger, size: :sm) - elsif hook.temporarily_disabled? - %span.gl-badge.badge-warning.badge-pill.sm= s_('Webhooks|Fails to connect') + = gl_badge_tag(s_('Webhooks|Fails to connect'), variant: :warning, size: :sm) %div - hook.class.triggers.each_value do |trigger| - if hook.public_send(trigger) - %span.gl-badge.badge-muted.badge-pill.sm.gl-mt-2.deploy-project-label= trigger.to_s.titleize - %span.gl-badge.badge-muted.badge-pill.sm.gl-mt-2 - = _('SSL Verification:') - = hook.enable_ssl_verification ? _('enabled') : _('disabled') + = gl_badge_tag(trigger.to_s.titleize, size: :sm) + = gl_badge_tag(sslBadgeText, size: :sm) .col-md-4.col-lg-5.text-right-md.gl-mt-2 %span>= render 'shared/web_hooks/test_button', hook: hook, button_class: 'btn-sm btn-default gl-mr-3' diff --git a/config/feature_flags/development/hide_access_tokens.yml b/config/feature_flags/development/work_items_hierarchy.yml similarity index 55% rename from config/feature_flags/development/hide_access_tokens.yml rename to config/feature_flags/development/work_items_hierarchy.yml index 1607780a0d0..8699511ea63 100644 --- a/config/feature_flags/development/hide_access_tokens.yml +++ b/config/feature_flags/development/work_items_hierarchy.yml @@ -1,8 +1,8 @@ --- -name: hide_access_tokens -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/76280 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/347490 -milestone: '14.6' +name: work_items_hierarchy +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/76720 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/350451 +milestone: '14.7' type: development -group: group::access -default_enabled: true +group: group::product planning +default_enabled: false diff --git a/config/routes/project.rb b/config/routes/project.rb index 702ef64a2ca..004206e0ae2 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -641,6 +641,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do post :generate_new_export get :download_export get :activity + get :planning_hierarchy get :refs put :new_issuable_address get :unfoldered_environment_names diff --git a/data/deprecations/14-7-pseudonymizer.yml b/data/deprecations/14-7-pseudonymizer.yml index 34aecb55bd7..bd8cb215496 100644 --- a/data/deprecations/14-7-pseudonymizer.yml +++ b/data/deprecations/14-7-pseudonymizer.yml @@ -1,8 +1,8 @@ - name: "Pseudonymizer" # The name of the feature to be deprecated announcement_milestone: "14.7" # The milestone when this feature was first announced as deprecated. - announcement_date: "2021-01-22" # The date of the milestone release when this feature was first announced as deprecated. This should almost always be the 22nd of a month (YYYY-MM-22), unless you did an out of band blog post. + announcement_date: "2022-01-22" # The date of the milestone release when this feature was first announced as deprecated. This should almost always be the 22nd of a month (YYYY-MM-22), unless you did an out of band blog post. removal_milestone: "15.0" # The milestone when this feature is planned to be removed - removal_date: "2021-05-22" # This should almost always be the 22nd of a month (YYYY-MM-22), the date of the milestone release when this feature is planned to be removed. + removal_date: "2022-05-22" # This should almost always be the 22nd of a month (YYYY-MM-22), the date of the milestone release when this feature is planned to be removed. body: | # Do not modify this line, instead modify the lines below. The Pseudonymizer feature is generally unused, can cause production issues with large databases, diff --git a/data/deprecations/14-7-sidekiq-metrics-health-check-donfig.yml b/data/deprecations/14-7-sidekiq-metrics-health-check-donfig.yml new file mode 100644 index 00000000000..e81c85a820f --- /dev/null +++ b/data/deprecations/14-7-sidekiq-metrics-health-check-donfig.yml @@ -0,0 +1,30 @@ +- name: "Sidekiq metrics and health checks configuration" + announcement_milestone: "14.7" + announcement_date: "2021-01-22" + removal_milestone: "15.0" + removal_date: "2022-05-22" + breaking_change: true + body: | # Do not modify this line, instead modify the lines below. + Exporting Sidekiq metrics and health checks using a single process and port is deprecated. + Support will be removed in 15.0. + + We have updated Sidekiq to export [metrics and health checks from two separate processes](https://gitlab.com/groups/gitlab-org/-/epics/6409) + to improve stability and availability and prevent data loss in edge cases. + As those are two separate servers, a configuration change will be required in 15.0 + to explicitly set separate ports for metrics and health-checks. + The newly introduced settings for `sidekiq['health_checks_*']` + should always be set in `gitlab.rb`. + For more information, check the documentation for [configuring Sidekiq](https://docs.gitlab.com/ee/administration/sidekiq.html). + + These changes also require updates in either Prometheus to scrape the new endpoint or k8s health-checks to target the new + health-check port to work properly, otherwise either metrics or health-checks will disappear. + + For the deprecation period those settings are optional + and GitLab will default the Sidekiq health-checks port to the same port as `sidekiq_exporter` + and only run one server (not changing the current behaviour). + Only if they are both set and a different port is provided, a separate metrics server will spin up + to serve the Sidekiq metrics, similar to the way Sidekiq will behave in 15.0. + stage: Enablement + tiers: [Free, Premium, Ultimate] + issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/347509 + documentation_url: https://docs.gitlab.com/ee/administration/sidekiq.html diff --git a/db/post_migrate/20211206161271_add_indexes_for_primary_email_cleanup_migration.rb b/db/post_migrate/20211206161271_add_indexes_for_primary_email_cleanup_migration.rb new file mode 100644 index 00000000000..0e370dfc5f8 --- /dev/null +++ b/db/post_migrate/20211206161271_add_indexes_for_primary_email_cleanup_migration.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class AddIndexesForPrimaryEmailCleanupMigration < Gitlab::Database::Migration[1.0] + USERS_INDEX = :index_users_on_id_for_primary_email_migration + EMAIL_INDEX = :index_emails_on_email_user_id + + disable_ddl_transaction! + + def up + unless index_exists_by_name?(:users, USERS_INDEX) + + disable_statement_timeout do + execute <<~SQL + CREATE INDEX CONCURRENTLY #{USERS_INDEX} + ON users (id) INCLUDE (email, confirmed_at) + WHERE confirmed_at IS NOT NULL + SQL + end + end + + add_concurrent_index :emails, [:email, :user_id], name: EMAIL_INDEX + end + + def down + remove_concurrent_index_by_name :users, USERS_INDEX + remove_concurrent_index_by_name :emails, EMAIL_INDEX + end +end diff --git a/db/post_migrate/20211206162601_cleanup_after_add_primary_email_to_emails_if_user_confirmed.rb b/db/post_migrate/20211206162601_cleanup_after_add_primary_email_to_emails_if_user_confirmed.rb new file mode 100644 index 00000000000..14f6c751e4d --- /dev/null +++ b/db/post_migrate/20211206162601_cleanup_after_add_primary_email_to_emails_if_user_confirmed.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class CleanupAfterAddPrimaryEmailToEmailsIfUserConfirmed < Gitlab::Database::Migration[1.0] + disable_ddl_transaction! + + MIGRATION_NAME = 'AddPrimaryEmailToEmailsIfUserConfirmed' + BATCH_SIZE = 10_000 + + # Stubbed class to access the User table + class User < ActiveRecord::Base + include ::EachBatch + + self.table_name = 'users' + self.inheritance_column = :_type_disabled + + scope :confirmed, -> { where.not(confirmed_at: nil) } + + has_many :emails + end + + # Stubbed class to access the Emails table + class Email < ActiveRecord::Base + self.table_name = 'emails' + self.inheritance_column = :_type_disabled + + belongs_to :user + end + + def up + finalize_background_migration(MIGRATION_NAME) + + # Select confirmed users that do not have their primary email in the emails table, + # and create the email record. There should be none if the background migration + # completed, but in case there is any leftover, we deal with it synchronously. + not_exists_condition = 'NOT EXISTS (SELECT 1 FROM emails WHERE emails.email = users.email AND emails.user_id = users.id)' + + User.confirmed.each_batch(of: BATCH_SIZE) do |user_batch| + user_batch.select(:id, :email, :confirmed_at).where(not_exists_condition).each do |user| + current_time = Time.now.utc + + begin + Email.create( + user_id: user.id, + email: user.email, + confirmed_at: user.confirmed_at, + created_at: current_time, + updated_at: current_time + ) + rescue StandardError => error + Gitlab::AppLogger.error("Could not add primary email #{user.email} to emails for user with ID #{user.id} due to #{error}") + end + end + end + end + + def down + # Intentionally left blank + end +end diff --git a/db/post_migrate/20211220064757_drop_temporary_indexes_for_primary_email_migration.rb b/db/post_migrate/20211220064757_drop_temporary_indexes_for_primary_email_migration.rb new file mode 100644 index 00000000000..1d61aec401e --- /dev/null +++ b/db/post_migrate/20211220064757_drop_temporary_indexes_for_primary_email_migration.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class DropTemporaryIndexesForPrimaryEmailMigration < Gitlab::Database::Migration[1.0] + USERS_INDEX = :index_users_on_id_for_primary_email_migration + EMAIL_INDEX = :index_emails_on_email_user_id + + disable_ddl_transaction! + + def up + remove_concurrent_index_by_name :users, USERS_INDEX + remove_concurrent_index_by_name :emails, EMAIL_INDEX + end + + def down + unless index_exists_by_name?(:users, USERS_INDEX) + + disable_statement_timeout do + execute <<~SQL + CREATE INDEX CONCURRENTLY #{USERS_INDEX} + ON users (id) INCLUDE (email, confirmed_at) + WHERE confirmed_at IS NOT NULL + SQL + end + end + + add_concurrent_index :emails, [:email, :user_id], name: EMAIL_INDEX + end +end diff --git a/db/schema_migrations/20211206161271 b/db/schema_migrations/20211206161271 new file mode 100644 index 00000000000..8a2561eec24 --- /dev/null +++ b/db/schema_migrations/20211206161271 @@ -0,0 +1 @@ +fa4a39c3bea70d31e8144f8830ef0353f22a7a663a891d9043e79f362058fbde \ No newline at end of file diff --git a/db/schema_migrations/20211206162601 b/db/schema_migrations/20211206162601 new file mode 100644 index 00000000000..5e19e21507d --- /dev/null +++ b/db/schema_migrations/20211206162601 @@ -0,0 +1 @@ +529c7ea38bbaa0c29491c2dfdb654a4a6adba93122d9bc23d6632526ff7fdb05 \ No newline at end of file diff --git a/db/schema_migrations/20211220064757 b/db/schema_migrations/20211220064757 new file mode 100644 index 00000000000..675596f1ce6 --- /dev/null +++ b/db/schema_migrations/20211220064757 @@ -0,0 +1 @@ +34bfe07fff59a415540ca2c5c96b33dc9030c15b2ffbb30cb7deedeb939ae132 \ No newline at end of file diff --git a/doc/administration/logs.md b/doc/administration/logs.md index 92167953441..0d7635405d6 100644 --- a/doc/administration/logs.md +++ b/doc/administration/logs.md @@ -28,14 +28,14 @@ A logger emits a log message only if its log level is equal to or above the mini The following log levels are supported: -| Level | Name | -|-------|---------| -| 0 | DEBUG | -| 1 | INFO | -| 2 | WARN | -| 3 | ERROR | -| 4 | FATAL | -| 5 | UNKNOWN | +| Level | Name | +|:------|:----------| +| 0 | `DEBUG` | +| 1 | `INFO` | +| 2 | `WARN` | +| 3 | `ERROR` | +| 4 | `FATAL` | +| 5 | `UNKNOWN` | GitLab loggers emit all log messages because they are set to `DEBUG` by default. @@ -53,8 +53,8 @@ GITLAB_LOG_LEVEL=info For some services, other log levels are in place that are not affected by this setting. Some of these services have their own environment variables to override the log level. For example: -| Service | Log Level | Environment variable | -|----------------------|-----------|----------------------| +| Service | Log level | Environment variable | +|:---------------------|:----------|:---------------------| | GitLab API | `INFO` | | | GitLab Cleanup | `INFO` | `DEBUG` | | GitLab Doctor | `INFO` | `VERBOSE` | @@ -84,26 +84,26 @@ are written to a file called `current`. The `logrotate` service built into GitLa [manages all logs](https://docs.gitlab.com/omnibus/settings/logs.html#logrotate) except those captured by `runit`. -| Log type | Managed by logrotate | Managed by svlogd/runit | -|-------------------------------------------------|------------------------|-------------------------| -| [Alertmanager Logs](#alertmanager-logs) | **{dotted-circle}** No | **{check-circle}** Yes | -| [Crond Logs](#crond-logs) | **{dotted-circle}** No | **{check-circle}** Yes | -| [Gitaly](#gitaly-logs) | **{check-circle}** Yes | **{check-circle}** Yes | -| [GitLab Exporter for Omnibus](#gitlab-exporter) | **{dotted-circle}** No | **{check-circle}** Yes | -| [GitLab Pages Logs](#pages-logs) | **{check-circle}** Yes | **{check-circle}** Yes | -| GitLab Rails | **{check-circle}** Yes | **{dotted-circle}** No | -| [GitLab Shell Logs](#gitlab-shelllog) | **{check-circle}** Yes | **{dotted-circle}** No | -| [Grafana Logs](#grafana-logs) | **{dotted-circle}** No | **{check-circle}** Yes | -| [LogRotate Logs](#logrotate-logs) | **{dotted-circle}** No | **{check-circle}** Yes | -| [Mailroom](#mail_room_jsonlog-default) | **{check-circle}** Yes | **{check-circle}** Yes | -| [NGINX](#nginx-logs) | **{check-circle}** Yes | **{check-circle}** Yes | -| [PostgreSQL Logs](#postgresql-logs) | **{dotted-circle}** No | **{check-circle}** Yes | -| [Praefect Logs](#praefect-logs) | **{dotted-circle}** Yes| **{check-circle}** Yes | -| [Prometheus Logs](#prometheus-logs) | **{dotted-circle}** No | **{check-circle}** Yes | -| [Puma](#puma-logs) | **{check-circle}** Yes | **{check-circle}** Yes | -| [Redis Logs](#redis-logs) | **{dotted-circle}** No | **{check-circle}** Yes | -| [Registry Logs](#registry-logs) | **{dotted-circle}** No | **{check-circle}** Yes | -| [Workhorse Logs](#workhorse-logs) | **{check-circle}** Yes | **{check-circle}** Yes | +| Log type | Managed by logrotate | Managed by svlogd/runit | +|:------------------------------------------------|:------------------------|:------------------------| +| [Alertmanager Logs](#alertmanager-logs) | **{dotted-circle}** No | **{check-circle}** Yes | +| [Crond Logs](#crond-logs) | **{dotted-circle}** No | **{check-circle}** Yes | +| [Gitaly](#gitaly-logs) | **{check-circle}** Yes | **{check-circle}** Yes | +| [GitLab Exporter for Omnibus](#gitlab-exporter) | **{dotted-circle}** No | **{check-circle}** Yes | +| [GitLab Pages Logs](#pages-logs) | **{check-circle}** Yes | **{check-circle}** Yes | +| GitLab Rails | **{check-circle}** Yes | **{dotted-circle}** No | +| [GitLab Shell Logs](#gitlab-shelllog) | **{check-circle}** Yes | **{dotted-circle}** No | +| [Grafana Logs](#grafana-logs) | **{dotted-circle}** No | **{check-circle}** Yes | +| [LogRotate Logs](#logrotate-logs) | **{dotted-circle}** No | **{check-circle}** Yes | +| [Mailroom](#mail_room_jsonlog-default) | **{check-circle}** Yes | **{check-circle}** Yes | +| [NGINX](#nginx-logs) | **{check-circle}** Yes | **{check-circle}** Yes | +| [PostgreSQL Logs](#postgresql-logs) | **{dotted-circle}** No | **{check-circle}** Yes | +| [Praefect Logs](#praefect-logs) | **{dotted-circle}** Yes | **{check-circle}** Yes | +| [Prometheus Logs](#prometheus-logs) | **{dotted-circle}** No | **{check-circle}** Yes | +| [Puma](#puma-logs) | **{check-circle}** Yes | **{check-circle}** Yes | +| [Redis Logs](#redis-logs) | **{dotted-circle}** No | **{check-circle}** Yes | +| [Registry Logs](#registry-logs) | **{dotted-circle}** No | **{check-circle}** Yes | +| [Workhorse Logs](#workhorse-logs) | **{check-circle}** Yes | **{check-circle}** Yes | ## `production_json.log` diff --git a/doc/administration/operations/fast_ssh_key_lookup.md b/doc/administration/operations/fast_ssh_key_lookup.md index c99f94589d7..ddedb3fe76a 100644 --- a/doc/administration/operations/fast_ssh_key_lookup.md +++ b/doc/administration/operations/fast_ssh_key_lookup.md @@ -27,13 +27,13 @@ lookup of authorized SSH keys. WARNING: OpenSSH version 6.9+ is required because `AuthorizedKeysCommand` must be -able to accept a fingerprint. Check the version of OpenSSH on your server. +able to accept a fingerprint. Check the version of OpenSSH on your server with `sshd -V`. ## Fast lookup is required for Geo **(PREMIUM)** By default, GitLab manages an `authorized_keys` file that is located in the `git` user's home directory. For most installations, this will be located under -`/var/opt/gitlab/.ssh/authorized_keys`, but you can use the following command to locate the `authorized_keys` on your system.: +`/var/opt/gitlab/.ssh/authorized_keys`, but you can use the following command to locate the `authorized_keys` on your system: ```shell getent passwd git | cut -d: -f6 | awk '{print $1"/.ssh/authorized_keys"}' @@ -77,9 +77,13 @@ sudo service sshd reload ``` Confirm that SSH is working by commenting out your user's key in the `authorized_keys` -file (start the line with a `#` to comment it), and attempting to pull a repository. +file (start the line with a `#` to comment it), and from your local machine, attempt to pull a repository or run: -A successful pull would mean that GitLab was able to find the key in the database, +```shell +ssh -T git@gitlab.example.com +``` + +A successful pull or [welcome message](../../ssh/index.md#verify-that-you-can-connect) would mean that GitLab was able to find the key in the database, since it is not present in the file anymore. NOTE: @@ -114,7 +118,7 @@ adding a new one, and attempting to pull a repository. Then you can backup and delete your `authorized_keys` file for best performance. The current users' keys are already present in the database, so there is no need for migration -or for asking users to re-add their keys. +or for users to re-add their keys. ## How to go back to using the `authorized_keys` file diff --git a/doc/ci/pipelines/settings.md b/doc/ci/pipelines/settings.md index cb225a306c9..85824dfb7c7 100644 --- a/doc/ci/pipelines/settings.md +++ b/doc/ci/pipelines/settings.md @@ -159,7 +159,8 @@ in the `.gitlab-ci.yml` file. ## Limit the number of changes fetched during clone -> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/28919) in GitLab 12.0. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/28919) in GitLab 12.0. +> - [Changed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/77576) `git depth` value in GitLab 14.7. You can limit the number of changes that GitLab CI/CD fetches when it clones a repository. @@ -171,8 +172,8 @@ a repository. The maximum value is `1000`. To disable shallow clone and make GitLab CI/CD fetch all branches and tags each time, keep the value empty or set to `0`. -In GitLab 12.0 and later, newly created projects automatically have a default -`git depth` value of `50`. +In GitLab versions 14.7 and later, newly created projects have a default `git depth` +value of `20`. GitLab versions 14.6 and earlier have a default `git depth` value of `50`. This value can be overridden by the [`GIT_DEPTH` variable](../large_repositories/index.md#shallow-cloning) in the `.gitlab-ci.yml` file. diff --git a/doc/update/deprecations.md b/doc/update/deprecations.md index c1e00c2f57b..00675fe5cd4 100644 --- a/doc/update/deprecations.md +++ b/doc/update/deprecations.md @@ -335,7 +335,7 @@ can cause production issues with large databases, and can interfere with object storage development. It is now considered deprecated, and will be removed in GitLab 15.0. -Planned removal milestone: 15.0 (2021-05-22) +Planned removal milestone: 15.0 (2022-05-22) ### Removal of Static Site Editor @@ -352,6 +352,30 @@ only supported report file in 15.0, but this is the first step towards GitLab su Planned removal milestone: 15.0 (2022-05-22) +### Sidekiq metrics and health checks configuration + +Exporting Sidekiq metrics and health checks using a single process and port is deprecated. +Support will be removed in 15.0. + +We have updated Sidekiq to export [metrics and health checks from two separate processes](https://gitlab.com/groups/gitlab-org/-/epics/6409) +to improve stability and availability and prevent data loss in edge cases. +As those are two separate servers, a configuration change will be required in 15.0 +to explicitly set separate ports for metrics and health-checks. +The newly introduced settings for `sidekiq['health_checks_*']` +should always be set in `gitlab.rb`. +For more information, check the documentation for [configuring Sidekiq](https://docs.gitlab.com/ee/administration/sidekiq.html). + +These changes also require updates in either Prometheus to scrape the new endpoint or k8s health-checks to target the new +health-check port to work properly, otherwise either metrics or health-checks will disappear. + +For the deprecation period those settings are optional +and GitLab will default the Sidekiq health-checks port to the same port as `sidekiq_exporter` +and only run one server (not changing the current behaviour). +Only if they are both set and a different port is provided, a separate metrics server will spin up +to serve the Sidekiq metrics, similar to the way Sidekiq will behave in 15.0. + +Planned removal milestone: 15.0 (2022-05-22) + ### Tracing in GitLab Tracing in GitLab is an integration with Jaeger, an open-source end-to-end distributed tracing system. GitLab users can navigate to their Jaeger instance to gain insight into the performance of a deployed application, tracking each function or microservice that handles a given request. Tracing in GitLab is deprecated in GitLab 14.7, and scheduled for removal in 15.0. To track work on a possible replacement, see the issue for [Opstrace integration with GitLab](https://gitlab.com/groups/gitlab-org/-/epics/6976). diff --git a/doc/update/index.md b/doc/update/index.md index 35f320841c7..3a17d3c01d7 100644 --- a/doc/update/index.md +++ b/doc/update/index.md @@ -537,6 +537,31 @@ See [Maintenance mode issue in GitLab 13.9 to 14.4](#maintenance-mode-issue-in-g - See [Maintenance mode issue in GitLab 13.9 to 14.4](#maintenance-mode-issue-in-gitlab-139-to-144). +- For GitLab Enterprise Edition customers, we noticed an issue when [subscription expiration is upcoming, and you create new subgroups and projects](https://gitlab.com/gitlab-org/gitlab/-/issues/322546). If you fall under that category and get 500 errors, you can work around this issue: + + 1. SSH into you GitLab server, and open a Rails console: + + ```shell + sudo gitlab-rails console + ``` + + 1. Disable the following features: + + ```ruby + Feature.disable(:subscribable_subscription_banner) + Feature.disable(:subscribable_license_banner) + ``` + + 1. Restart Puma or Unicorn: + + ```shell + #For installations using Puma + sudo gitlab-ctl restart puma + + #For installations using Unicorn + sudo gitlab-ctl restart unicorn + ``` + ### 13.8.8 GitLab 13.8 includes a background migration to address [an issue with duplicate service records](https://gitlab.com/gitlab-org/gitlab/-/issues/290008). If duplicate services are present, this background migration must complete before a unique index is applied to the services table, which was [introduced in GitLab 13.9](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/52563). Upgrades from GitLab 13.8 and earlier to later versions must include an intermediate upgrade to GitLab 13.8.8 and [must wait until the background migrations complete](#checking-for-background-migrations-before-upgrading) before proceeding. diff --git a/doc/user/application_security/dependency_scanning/index.md b/doc/user/application_security/dependency_scanning/index.md index 4ef400aae98..a8cc33d5545 100644 --- a/doc/user/application_security/dependency_scanning/index.md +++ b/doc/user/application_security/dependency_scanning/index.md @@ -63,6 +63,9 @@ vulnerability. ## Requirements +Dependency Scanning runs in the `test` stage, which is available by default. If you redefine the +stages in the `.gitlab-ci.yml` file, the `test` stage is required. + To run dependency scanning jobs, by default, you need GitLab Runner with the [`docker`](https://docs.gitlab.com/runner/executors/docker.html) or [`kubernetes`](https://docs.gitlab.com/runner/install/kubernetes.html) executor. diff --git a/doc/user/application_security/iac_scanning/index.md b/doc/user/application_security/iac_scanning/index.md index f49c6bf47d4..4d5c1da3c47 100644 --- a/doc/user/application_security/iac_scanning/index.md +++ b/doc/user/application_security/iac_scanning/index.md @@ -14,6 +14,8 @@ Currently, IaC scanning supports configuration files for Terraform, Ansible, AWS ## Requirements +IaC Scanning runs in the `test` stage, which is available by default. If you redefine the stages in the `.gitlab-ci.yml` file, the `test` stage is required. + To run IaC scanning jobs, by default, you need GitLab Runner with the [`docker`](https://docs.gitlab.com/runner/executors/docker.html) or [`kubernetes`](https://docs.gitlab.com/runner/install/kubernetes.html) executor. diff --git a/doc/user/application_security/secret_detection/index.md b/doc/user/application_security/secret_detection/index.md index f897d9c6e5b..c5761a5743f 100644 --- a/doc/user/application_security/secret_detection/index.md +++ b/doc/user/application_security/secret_detection/index.md @@ -370,10 +370,10 @@ For information on this, see the [general Application Security troubleshooting s ### Error: `Couldn't run the gitleaks command: exit status 2` -If a pipeline is triggered from a Merge Request containing 60 commits while the `GIT_DEPTH` variable -is set to 50 (a [project default](../../../ci/pipelines/settings.md#limit-the-number-of-changes-fetched-during-clone)), -the Secret Detection job fails as the clone is not deep enough to contain all of the -relevant commits. +If a pipeline is triggered from a Merge Request containing 60 commits while the `GIT_DEPTH` variable's +value is less than that, the Secret Detection job fails as the clone is not deep enough to contain all of the +relevant commits. For information on the current default value, see the +[pipeline configuration documentation](../../../ci/pipelines/settings.md#limit-the-number-of-changes-fetched-during-clone). To confirm this as the cause of the error, set the [logging level](../../application_security/secret_detection/index.md#logging-level) to `debug`, then diff --git a/doc/user/group/planning_hierarchy/img/view-project-work-item-hierarchy_v14_7.png b/doc/user/group/planning_hierarchy/img/view-project-work-item-hierarchy_v14_7.png new file mode 100644 index 0000000000000000000000000000000000000000..2ddd551ee46222230b2171785650b5e9404f0422 GIT binary patch literal 38783 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 [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/340844/) in GitLab 14.7 and is behind the feature flag `work_items_hierarchy`. + +To view the planning hierarchy in a project: + +1. On the top bar, select **Menu > Projects** and find your project. +1. On the left sidebar, select **Project information > Planning hierarchy**. + +Under **Current structure**, you can see a hierarchy diagram that matches your current planning hierarchy. +The work items outside your subscription plan show up below **Unavailable structure**. + +![Screenshot showing hierarchy page](img/view-project-work-item-hierarchy_v14_7.png) + ## Hierarchies with epics With epics, you can achieve the following hierarchy: diff --git a/lib/gitlab/ci/templates/Jobs/Secret-Detection.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/Secret-Detection.gitlab-ci.yml index 07d54ae2239..42487cc0c67 100644 --- a/lib/gitlab/ci/templates/Jobs/Secret-Detection.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Jobs/Secret-Detection.gitlab-ci.yml @@ -14,6 +14,8 @@ variables: image: "$SECURE_ANALYZERS_PREFIX/secrets:$SECRETS_ANALYZER_VERSION" services: [] allow_failure: true + variables: + GIT_DEPTH: "50" # `rules` must be overridden explicitly by each child job # see https://gitlab.com/gitlab-org/gitlab/-/issues/218444 artifacts: diff --git a/lib/sidebars/concerns/work_item_hierarchy.rb b/lib/sidebars/concerns/work_item_hierarchy.rb new file mode 100644 index 00000000000..f88e24b205d --- /dev/null +++ b/lib/sidebars/concerns/work_item_hierarchy.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# This module has the necessary methods to render +# work items hierarchy menu +module Sidebars + module Concerns + module WorkItemHierarchy + def hierarchy_menu_item(container, url, path) + unless show_hierarachy_menu_item?(container) + return ::Sidebars::NilMenuItem.new(item_id: :hierarchy) + end + + ::Sidebars::MenuItem.new( + title: _('Planning hierarchy'), + link: url, + active_routes: { path: path }, + item_id: :hierarchy + ) + end + + def show_hierarachy_menu_item?(container) + Feature.enabled?(:work_items_hierarchy, container, default_enabled: :yaml) && + can?(context.current_user, :read_work_items_hierarchy, container) + end + end + end +end diff --git a/lib/sidebars/projects/menus/project_information_menu.rb b/lib/sidebars/projects/menus/project_information_menu.rb index 44b94ee3522..ad11a383757 100644 --- a/lib/sidebars/projects/menus/project_information_menu.rb +++ b/lib/sidebars/projects/menus/project_information_menu.rb @@ -4,10 +4,13 @@ module Sidebars module Projects module Menus class ProjectInformationMenu < ::Sidebars::Menu + include ::Sidebars::Concerns::WorkItemHierarchy + override :configure_menu_items def configure_menu_items add_item(activity_menu_item) add_item(labels_menu_item) + add_item(hierarchy_menu_item(context.project, planning_hierarchy_project_path(context.project), 'projects#planning_hierarchy')) add_item(members_menu_item) true diff --git a/locale/gitlab.pot b/locale/gitlab.pot index c3d67ca8f8f..d2fcecb165b 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1835,21 +1835,12 @@ msgstr "" msgid "AccessTokens|Keep this token secret. Anyone who has it can access repository static objects as if they were you. If that ever happens, %{linkStart}reset this token%{linkEnd}." msgstr "" -msgid "AccessTokens|Keep this token secret. Anyone who has it can access repository static objects as if they were you. If that ever happens, %{reset_link_start}reset this token%{reset_link_end}." -msgstr "" - msgid "AccessTokens|Keep this token secret. Anyone who has it can create issues as if they were you. If that happens, %{linkStart}reset this token%{linkEnd}." msgstr "" -msgid "AccessTokens|Keep this token secret. Anyone who has it can create issues as if they were you. If that happens, %{link_reset_it}." -msgstr "" - msgid "AccessTokens|Keep this token secret. Anyone who has it can read activity and issue RSS feeds or your calendar feed as if they were you. If that happens, %{linkStart}reset this token%{linkEnd}." msgstr "" -msgid "AccessTokens|Keep this token secret. Anyone who has it can read activity and issue RSS feeds or your calendar feed as if they were you. If that happens, %{link_reset_it}." -msgstr "" - msgid "AccessTokens|Personal Access Tokens" msgstr "" @@ -1874,9 +1865,6 @@ msgstr "" msgid "AccessTokens|Your static object token authenticates you when repository static objects (such as archives or blobs) are served from an external storage." msgstr "" -msgid "AccessTokens|reset this token" -msgstr "" - msgid "AccessibilityReport|Learn more" msgstr "" @@ -7083,6 +7071,9 @@ msgstr "" msgid "Child" msgstr "" +msgid "Child epic" +msgstr "" + msgid "Child epic does not exist." msgstr "" @@ -17628,6 +17619,33 @@ msgstr[1] "" msgid "Hide values" msgstr "" +msgid "Hierarchy|Current structure" +msgstr "" + +msgid "Hierarchy|Deliver value more efficiently by breaking down necessary work into a hierarchical structure. This structure helps teams understand scope, priorities, and how work cascades up toward larger goals." +msgstr "" + +msgid "Hierarchy|Help us improve work items in GitLab!" +msgstr "" + +msgid "Hierarchy|Is there a framework or type of work item you wish you had access to in GitLab? Give us your feedback and help us build the experiences valuable to you." +msgstr "" + +msgid "Hierarchy|Planning hierarchy" +msgstr "" + +msgid "Hierarchy|Take the work items survey" +msgstr "" + +msgid "Hierarchy|These items are unavailable in the current structure." +msgstr "" + +msgid "Hierarchy|Unavailable structure" +msgstr "" + +msgid "Hierarchy|You can start using these items now." +msgstr "" + msgid "High or unknown vulnerabilities present" msgstr "" @@ -26512,6 +26530,9 @@ msgstr "" msgid "Plan:" msgstr "" +msgid "Planning hierarchy" +msgstr "" + msgid "PlantUML" msgstr "" @@ -30235,6 +30256,9 @@ msgstr "" msgid "Required only if you are not using role instance credentials." msgstr "" +msgid "Requirement" +msgstr "" + msgid "Requirement %{reference} has been added" msgstr "" @@ -34918,6 +34942,9 @@ msgstr "" msgid "Target-Branch" msgstr "" +msgid "Task" +msgstr "" + msgid "Task ID: %{elastic_task}" msgstr "" @@ -35163,6 +35190,9 @@ msgstr "" msgid "Test Cases" msgstr "" +msgid "Test case" +msgstr "" + msgid "Test coverage parsing" msgstr "" diff --git a/qa/qa/page/project/settings/visibility_features_permissions.rb b/qa/qa/page/project/settings/visibility_features_permissions.rb index 5415595803f..60cea6de7f5 100644 --- a/qa/qa/page/project/settings/visibility_features_permissions.rb +++ b/qa/qa/page/project/settings/visibility_features_permissions.rb @@ -5,12 +5,9 @@ module QA module Project module Settings class VisibilityFeaturesPermissions < Page::Base - view 'app/helpers/projects_helper.rb' do - element :visibility_features_permissions_save_button - end - view 'app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue' do element :project_visibility_dropdown + element :visibility_features_permissions_save_button end def set_project_visibility(visibility) diff --git a/qa/qa/specs/features/browser_ui/3_create/merge_request/merge_merge_request_from_fork_spec.rb b/qa/qa/specs/features/browser_ui/3_create/merge_request/merge_merge_request_from_fork_spec.rb index d198d79c5fe..b0c6d01e8ca 100644 --- a/qa/qa/specs/features/browser_ui/3_create/merge_request/merge_merge_request_from_fork_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/merge_request/merge_merge_request_from_fork_spec.rb @@ -2,7 +2,11 @@ module QA RSpec.describe 'Create' do - describe 'Merge request creation from fork' do + describe 'Merge request creation from fork', quarantine: { + only: { subdomain: %i[canary production] }, + issue: "https://gitlab.com/gitlab-org/gitlab/-/issues/343801", + type: :investigation + } do let(:merge_request) do Resource::MergeRequestFromFork.fabricate_via_browser_ui! do |merge_request| merge_request.fork_branch = 'feature-branch' diff --git a/spec/controllers/concerns/work_items_hierarchy_spec.rb b/spec/controllers/concerns/work_items_hierarchy_spec.rb new file mode 100644 index 00000000000..270e30cb5f9 --- /dev/null +++ b/spec/controllers/concerns/work_items_hierarchy_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WorkItemsHierarchy do + controller(ApplicationController) do + include WorkItemsHierarchy + end + + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + + render_views + + before do + sign_in user + routes.draw { get :planning_hierarchy, to: "anonymous#planning_hierarchy" } + controller.instance_variable_set(:@project, project) + end + + it 'renders hierarchy' do + stub_feature_flags(work_items_hierarchy: true) + + get :planning_hierarchy + + expect(response).to have_gitlab_http_status(:ok) + expect(response.body).to match(/id="js-work-items-hierarchy"/) + end + + it 'renders 404' do + stub_feature_flags(work_items_hierarchy: false) + + get :planning_hierarchy + + expect(response).to have_gitlab_http_status(:not_found) + expect(response.body).not_to match(/id="js-work-items-hierarchy"/) + end +end diff --git a/spec/features/profile_spec.rb b/spec/features/profile_spec.rb index eeb9bf476c3..34eb07d78f1 100644 --- a/spec/features/profile_spec.rb +++ b/spec/features/profile_spec.rb @@ -62,66 +62,33 @@ RSpec.describe 'Profile account page', :js do end end - describe 'when I reset feed token' do - it 'resets feed token with `hide_access_tokens` feature flag enabled' do - visit profile_personal_access_tokens_path + it 'allows resetting of feed token' do + visit profile_personal_access_tokens_path - within('[data-testid="feed-token-container"]') do - previous_token = find_field('Feed token').value + within('[data-testid="feed-token-container"]') do + previous_token = find_field('Feed token').value - accept_confirm { click_link('reset this token') } + accept_confirm { click_link('reset this token') } - click_button('Click to reveal') + click_button('Click to reveal') - expect(find_field('Feed token').value).not_to eq(previous_token) - end - end - - it 'resets feed token with `hide_access_tokens` feature flag disabled' do - stub_feature_flags(hide_access_tokens: false) - visit profile_personal_access_tokens_path - - within('.feed-token-reset') do - previous_token = find("#feed_token").value - - accept_confirm { find('[data-testid="reset_feed_token_link"]').click } - - expect(find('#feed_token').value).not_to eq(previous_token) - end + expect(find_field('Feed token').value).not_to eq(previous_token) end end - describe 'when I reset incoming email token' do - before do - allow(Gitlab.config.incoming_email).to receive(:enabled).and_return(true) - stub_feature_flags(bootstrap_confirmation_modals: false) - end + it 'allows resetting of incoming email token' do + allow(Gitlab.config.incoming_email).to receive(:enabled).and_return(true) - it 'resets incoming email token with `hide_access_tokens` feature flag enabled' do - visit profile_personal_access_tokens_path + visit profile_personal_access_tokens_path - within('[data-testid="incoming-email-token-container"]') do - previous_token = find_field('Incoming email token').value + within('[data-testid="incoming-email-token-container"]') do + previous_token = find_field('Incoming email token').value - accept_confirm { click_link('reset this token') } + accept_confirm { click_link('reset this token') } - click_button('Click to reveal') + click_button('Click to reveal') - expect(find_field('Incoming email token').value).not_to eq(previous_token) - end - end - - it 'resets incoming email token with `hide_access_tokens` feature flag disabled' do - stub_feature_flags(hide_access_tokens: false) - visit profile_personal_access_tokens_path - - within('.incoming-email-token-reset') do - previous_token = find('#incoming_email_token').value - - accept_confirm { find('[data-testid="reset_email_token_link"]').click } - - expect(find('#incoming_email_token').value).not_to eq(previous_token) - end + expect(find_field('Incoming email token').value).not_to eq(previous_token) end end diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index 135a940807e..f1e5658cd7b 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -132,7 +132,7 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do describe "feed token" do context "when enabled" do - it "displays feed token with `hide_access_tokens` feature flag enabled" do + it "displays feed token" do allow(Gitlab::CurrentSettings).to receive(:disable_feed_token).and_return(false) visit profile_personal_access_tokens_path @@ -143,15 +143,6 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do expect(page).to have_content(feed_token_description) end end - - it "displays feed token with `hide_access_tokens` feature flag disabled" do - stub_feature_flags(hide_access_tokens: false) - allow(Gitlab::CurrentSettings).to receive(:disable_feed_token).and_return(false) - visit profile_personal_access_tokens_path - - expect(page).to have_field('Feed token', with: user.feed_token) - expect(page).to have_content(feed_token_description) - end end context "when disabled" do diff --git a/spec/features/projects/features_visibility_spec.rb b/spec/features/projects/features_visibility_spec.rb index a7e773dda2d..23fcc1fe444 100644 --- a/spec/features/projects/features_visibility_spec.rb +++ b/spec/features/projects/features_visibility_spec.rb @@ -24,7 +24,7 @@ RSpec.describe 'Edit Project Settings' do # disable by clicking toggle toggle_feature_off("project[project_feature_attributes][#{tool_name}_access_level]") page.within('.sharing-permissions') do - find('input[value="Save changes"]').click + find('[data-testid="project-features-save-button"]').click end wait_for_requests expect(page).not_to have_selector(".shortcuts-#{shortcut_name}") @@ -32,7 +32,7 @@ RSpec.describe 'Edit Project Settings' do # re-enable by clicking toggle again toggle_feature_on("project[project_feature_attributes][#{tool_name}_access_level]") page.within('.sharing-permissions') do - find('input[value="Save changes"]').click + find('[data-testid="project-features-save-button"]').click end wait_for_requests expect(page).to have_selector(".shortcuts-#{shortcut_name}") diff --git a/spec/features/projects/settings/project_settings_spec.rb b/spec/features/projects/settings/project_settings_spec.rb index 71b319d192c..b67caa5a5f9 100644 --- a/spec/features/projects/settings/project_settings_spec.rb +++ b/spec/features/projects/settings/project_settings_spec.rb @@ -47,7 +47,7 @@ RSpec.describe 'Projects settings' do # disable by clicking toggle forking_enabled_button.click page.within('.sharing-permissions') do - find('input[value="Save changes"]').click + find('[data-testid="project-features-save-button"]').click end wait_for_requests @@ -77,7 +77,7 @@ RSpec.describe 'Projects settings' do expect(default_award_emojis_input.value).to eq('false') page.within('.sharing-permissions') do - find('input[value="Save changes"]').click + find('[data-testid="project-features-save-button"]').click end wait_for_requests diff --git a/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb b/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb index 862bae45fc6..77be351f3d8 100644 --- a/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb +++ b/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb @@ -54,7 +54,7 @@ RSpec.describe 'Projects > Settings > User manages merge request settings' do within('.sharing-permissions-form') do find('.project-feature-controls[data-for="project[project_feature_attributes][merge_requests_access_level]"] .gl-toggle').click - find('input[value="Save changes"]').send_keys(:return) + find('[data-testid="project-features-save-button"]').send_keys(:return) end expect(page).not_to have_content 'Pipelines must succeed' @@ -74,7 +74,7 @@ RSpec.describe 'Projects > Settings > User manages merge request settings' do within('.sharing-permissions-form') do find('.project-feature-controls[data-for="project[project_feature_attributes][builds_access_level]"] .gl-toggle').click - find('input[value="Save changes"]').send_keys(:return) + find('[data-testid="project-features-save-button"]').send_keys(:return) end expect(page).to have_content 'Pipelines must succeed' @@ -95,7 +95,7 @@ RSpec.describe 'Projects > Settings > User manages merge request settings' do within('.sharing-permissions-form') do find('.project-feature-controls[data-for="project[project_feature_attributes][merge_requests_access_level]"] .gl-toggle').click - find('input[value="Save changes"]').send_keys(:return) + find('[data-testid="project-features-save-button"]').send_keys(:return) end expect(page).to have_content 'Pipelines must succeed' diff --git a/spec/features/projects/user_changes_project_visibility_spec.rb b/spec/features/projects/user_changes_project_visibility_spec.rb index 10b9cf84256..68fed9b8a74 100644 --- a/spec/features/projects/user_changes_project_visibility_spec.rb +++ b/spec/features/projects/user_changes_project_visibility_spec.rb @@ -5,14 +5,6 @@ require 'spec_helper' RSpec.describe 'User changes public project visibility', :js do include ProjectForksHelper - before do - fork_project(project, project.owner) - - sign_in(project.owner) - - visit edit_project_path(project) - end - shared_examples 'changing visibility to private' do it 'requires confirmation' do visibility_select = first('.project-feature-controls .select-control') @@ -34,15 +26,85 @@ RSpec.describe 'User changes public project visibility', :js do end end - context 'when a project is public' do + shared_examples 'does not require confirmation' do + it 'saves without confirmation' do + visibility_select = first('.project-feature-controls .select-control') + visibility_select.select('Private') + + page.within('#js-shared-permissions') do + click_button 'Save changes' + end + + wait_for_requests + + expect(project.reload).to be_private + end + end + + context 'when the project has forks' do + before do + fork_project(project, project.owner) + + sign_in(project.owner) + + visit edit_project_path(project) + end + + context 'when a project is public' do + let(:project) { create(:project, :empty_repo, :public) } + + it_behaves_like 'changing visibility to private' + end + + context 'when the project is internal' do + let(:project) { create(:project, :empty_repo, :internal) } + + it_behaves_like 'changing visibility to private' + end + + context 'when the visibility level is untouched' do + let(:project) { create(:project, :empty_repo, :public) } + + it 'saves without confirmation' do + expect(page).to have_selector('.js-emails-disabled', visible: true) + find('.js-emails-disabled input[type="checkbox"]').click + + page.within('#js-shared-permissions') do + click_button 'Save changes' + end + + wait_for_requests + + expect(project.reload).to be_public + end + end + end + + context 'when the project is not forked' do let(:project) { create(:project, :empty_repo, :public) } - it_behaves_like 'changing visibility to private' + before do + sign_in(project.owner) + + visit edit_project_path(project) + end + + it_behaves_like 'does not require confirmation' end - context 'when the project is internal' do - let(:project) { create(:project, :empty_repo, :internal) } + context 'with unlink_fork_network_upon_visibility_decrease = false' do + let(:project) { create(:project, :empty_repo, :public) } - it_behaves_like 'changing visibility to private' + before do + stub_feature_flags(unlink_fork_network_upon_visibility_decrease: false) + + fork_project(project, project.owner) + + sign_in(project.owner) + + visit edit_project_path(project) + end + + it_behaves_like 'does not require confirmation' end end diff --git a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js index 0020269e4e7..8a9bb025d55 100644 --- a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js +++ b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js @@ -7,6 +7,7 @@ import { visibilityLevelDescriptions, visibilityOptions, } from '~/pages/projects/shared/permissions/constants'; +import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue'; const defaultProps = { currentSettings: { @@ -47,6 +48,8 @@ const defaultProps = { packagesAvailable: false, packagesHelpPath: '/help/user/packages/index', requestCveAvailable: true, + confirmationPhrase: 'my-fake-project', + showVisibilityConfirmModal: false, }; describe('Settings Panel', () => { @@ -104,6 +107,7 @@ describe('Settings Panel', () => { ); const findMetricsVisibilitySettings = () => wrapper.find({ ref: 'metrics-visibility-settings' }); const findOperationsSettings = () => wrapper.find({ ref: 'operations-settings' }); + const findConfirmDangerButton = () => wrapper.findComponent(ConfirmDanger); afterEach(() => { wrapper.destroy(); @@ -177,6 +181,44 @@ describe('Settings Panel', () => { expect(findRequestAccessEnabledInput().exists()).toBe(false); }); + + it('does not require confirmation if the visibility is reduced', async () => { + wrapper = mountComponent({ + currentSettings: { visibilityLevel: visibilityOptions.INTERNAL }, + }); + + expect(findConfirmDangerButton().exists()).toBe(false); + + await findProjectVisibilityLevelInput().setValue(visibilityOptions.PRIVATE); + + expect(findConfirmDangerButton().exists()).toBe(false); + }); + + describe('showVisibilityConfirmModal=true', () => { + beforeEach(() => { + wrapper = mountComponent({ + currentSettings: { visibilityLevel: visibilityOptions.INTERNAL }, + showVisibilityConfirmModal: true, + }); + }); + + it('will render the confirmation dialog if the visibility is reduced', async () => { + expect(findConfirmDangerButton().exists()).toBe(false); + + await findProjectVisibilityLevelInput().setValue(visibilityOptions.PRIVATE); + + expect(findConfirmDangerButton().exists()).toBe(true); + }); + + it('emits the `confirm` event when the reduce visibility warning is confirmed', async () => { + expect(wrapper.emitted('confirm')).toBeUndefined(); + + await findProjectVisibilityLevelInput().setValue(visibilityOptions.PRIVATE); + await findConfirmDangerButton().vm.$emit('confirm'); + + expect(wrapper.emitted('confirm')).toHaveLength(1); + }); + }); }); describe('Issues settings', () => { diff --git a/spec/frontend/work_items_hierarchy/components/__snapshots__/app_spec.js.snap b/spec/frontend/work_items_hierarchy/components/__snapshots__/app_spec.js.snap new file mode 100644 index 00000000000..f4979e4e707 --- /dev/null +++ b/spec/frontend/work_items_hierarchy/components/__snapshots__/app_spec.js.snap @@ -0,0 +1,1197 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`WorkItemsHierarchy App when licensePlan is free matches the snapshot 1`] = ` +
+
+ + +
+
+ +
+ +
+

+ Help us improve work items in GitLab! +

+ +

+ + Is there a framework or type of work item you wish you had access to in GitLab? Give us your feedback and help us build the experiences valuable to you. + +

+ + + + + + + + Take the work items survey + + + +
+ + +
+ + +
+ +

+ Planning hierarchy +

+ +

+ + Deliver value more efficiently by breaking down necessary work into a hierarchical structure. This structure helps teams understand scope, priorities, and how work cascades up toward larger goals. + +

+ +
+ Current structure +
+ +

+ You can start using these items now. +

+ +
+
+ + + + + + + Issue + + + + + + +
+
+ + + + + + + Task + + + + + + +
+
+ + + + + + + Incident + + + + + + +
+
+ +
+ + Unavailable structure + +
+ +

+ + These items are unavailable in the current structure. + +

+ +
+
+ + + + + + + Epic + + + + + + Premium + + + +
+
+ + + + + + + Child epic + + + + + + Ultimate + + + +
+
+ + + + + + + Requirement + + + + + + Ultimate + + + +
+
+ + + + + + + Test case + + + + + + Ultimate + + + +
+
+
+`; + +exports[`WorkItemsHierarchy App when licensePlan is premium matches the snapshot 1`] = ` +
+
+ + +
+
+ +
+ +
+

+ Help us improve work items in GitLab! +

+ +

+ + Is there a framework or type of work item you wish you had access to in GitLab? Give us your feedback and help us build the experiences valuable to you. + +

+ + + + + + + + Take the work items survey + + + +
+ + +
+ + +
+ +

+ Planning hierarchy +

+ +

+ + Deliver value more efficiently by breaking down necessary work into a hierarchical structure. This structure helps teams understand scope, priorities, and how work cascades up toward larger goals. + +

+ +
+ Current structure +
+ +

+ You can start using these items now. +

+ +
+
+ + + + + + + Epic + + + + + +
+ + +
+ +
+ + + + + + + + + + Issue + + +
+
+
+ + + + + + + Task + + + + + + +
+
+ + + + + + + Incident + + + + + + +
+
+ +
+ + Unavailable structure + +
+ +

+ + These items are unavailable in the current structure. + +

+ +
+
+ + + + + + + Child epic + + + + + + Ultimate + + + +
+
+ + + + + + + Requirement + + + + + + Ultimate + + + +
+
+ + + + + + + Test case + + + + + + Ultimate + + + +
+
+
+`; + +exports[`WorkItemsHierarchy App when licensePlan is ultimate matches the snapshot 1`] = ` +
+
+ + +
+
+ +
+ +
+

+ Help us improve work items in GitLab! +

+ +

+ + Is there a framework or type of work item you wish you had access to in GitLab? Give us your feedback and help us build the experiences valuable to you. + +

+ + + + + + + + Take the work items survey + + + +
+ + +
+ + +
+ +

+ Planning hierarchy +

+ +

+ + Deliver value more efficiently by breaking down necessary work into a hierarchical structure. This structure helps teams understand scope, priorities, and how work cascades up toward larger goals. + +

+ +
+ Current structure +
+ +

+ You can start using these items now. +

+ +
+
+ + + + + + + Epic + + + + + +
+ + + + +
+ +
+ + + + + + + + + + Child epic + + +
+ +
+ + + + + + + + + + Issue + + +
+
+
+ + + + + + + Task + + + + + + +
+
+ + + + + + + Incident + + + + + + +
+
+ + + + + + + Requirement + + + + + + +
+
+ + + + + + + Test case + + + + + + +
+
+ + + + + +
+
+`; diff --git a/spec/frontend/work_items_hierarchy/components/app_spec.js b/spec/frontend/work_items_hierarchy/components/app_spec.js new file mode 100644 index 00000000000..c22c0fcb21c --- /dev/null +++ b/spec/frontend/work_items_hierarchy/components/app_spec.js @@ -0,0 +1,78 @@ +import { nextTick } from 'vue'; +import { createLocalVue, mount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import { GlBanner } from '@gitlab/ui'; +import App from '~/work_items_hierarchy/components/app.vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('WorkItemsHierarchy App', () => { + let wrapper; + const createComponent = (props = {}, data = {}) => { + wrapper = extendedWrapper( + mount(App, { + localVue, + provide: { + illustrationPath: '/foo.svg', + licensePlan: 'free', + ...props, + }, + data() { + return data; + }, + }), + ); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each` + licensePlan + ${'free'} + ${'premium'} + ${'ultimate'} + `('when licensePlan is $licensePlan', ({ licensePlan }) => { + beforeEach(() => { + createComponent({ licensePlan }); + }); + + it('matches the snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('survey banner', () => { + it('shows when the banner is visible', () => { + createComponent({}, { bannerVisible: true }); + + expect(wrapper.find(GlBanner).exists()).toBe(true); + }); + + it('hide when close is called', async () => { + createComponent({}, { bannerVisible: true }); + + wrapper.findByTestId('close-icon').trigger('click'); + + await nextTick(); + + expect(wrapper.find(GlBanner).exists()).toBe(false); + }); + }); + + describe('Unavailable structure', () => { + it.each` + licensePlan | visible + ${'free'} | ${true} + ${'premium'} | ${true} + ${'ultimate'} | ${false} + `('visibility is $visible when plan is $licensePlan', ({ licensePlan, visible }) => { + createComponent({ licensePlan }); + + expect(wrapper.findByTestId('unavailable-structure').exists()).toBe(visible); + }); + }); +}); diff --git a/spec/frontend/work_items_hierarchy/components/hierarchy_spec.js b/spec/frontend/work_items_hierarchy/components/hierarchy_spec.js new file mode 100644 index 00000000000..14c15fb5cbe --- /dev/null +++ b/spec/frontend/work_items_hierarchy/components/hierarchy_spec.js @@ -0,0 +1,118 @@ +import { createLocalVue, mount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import { GlBadge } from '@gitlab/ui'; +import Hierarchy from '~/work_items_hierarchy/components/hierarchy.vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import RESPONSE from '~/work_items_hierarchy/static_response'; +import { workItemTypes } from '~/work_items/constants'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('WorkItemsHierarchy Hierarchy', () => { + let wrapper; + + const workItemsFromResponse = (response) => { + return response.reduce( + (itemTypes, item) => { + const key = item.available ? 'available' : 'unavailable'; + itemTypes[key].push({ + ...item, + ...workItemTypes[item.type], + nestedTypes: item.nestedTypes + ? item.nestedTypes.map((type) => workItemTypes[type]) + : null, + }); + return itemTypes; + }, + { available: [], unavailable: [] }, + ); + }; + + const createComponent = (props = {}) => { + wrapper = extendedWrapper( + mount(Hierarchy, { + localVue, + propsData: { + workItemTypes: props.workItemTypes, + ...props, + }, + }), + ); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('available structure', () => { + let items = []; + + beforeEach(() => { + items = workItemsFromResponse(RESPONSE.ultimate).available; + createComponent({ workItemTypes: items }); + }); + + it('renders all work items', () => { + expect(wrapper.findAllByTestId('work-item-wrapper')).toHaveLength(items.length); + }); + + it('does not render badges', () => { + expect(wrapper.find(GlBadge).exists()).toBe(false); + }); + }); + + describe('unavailable structure', () => { + let items = []; + + beforeEach(() => { + items = workItemsFromResponse(RESPONSE.premium).unavailable; + createComponent({ workItemTypes: items }); + }); + + it('renders all work items', () => { + expect(wrapper.findAllByTestId('work-item-wrapper')).toHaveLength(items.length); + }); + + it('renders license badges for all work items', () => { + expect(wrapper.findAll(GlBadge)).toHaveLength(items.length); + }); + + it('does not render svg icon for linking', () => { + expect(wrapper.findByTestId('hierarchy-rounded-arrow-tail').exists()).toBe(false); + expect(wrapper.findByTestId('level-up-icon').exists()).toBe(false); + }); + }); + + describe('nested work items', () => { + describe.each` + licensePlan | arrowTailVisible | levelUpIconVisible | arrowDownIconVisible + ${'ultimate'} | ${true} | ${true} | ${true} + ${'premium'} | ${false} | ${false} | ${true} + ${'free'} | ${false} | ${false} | ${false} + `( + 'when $licensePlan license', + ({ licensePlan, arrowTailVisible, levelUpIconVisible, arrowDownIconVisible }) => { + let items = []; + beforeEach(() => { + items = workItemsFromResponse(RESPONSE[licensePlan]).available; + createComponent({ workItemTypes: items }); + }); + + it(`${arrowTailVisible ? 'render' : 'does not render'} arrow tail svg`, () => { + expect(wrapper.findByTestId('hierarchy-rounded-arrow-tail').exists()).toBe( + arrowTailVisible, + ); + }); + + it(`${levelUpIconVisible ? 'render' : 'does not render'} arrow tail svg`, () => { + expect(wrapper.findByTestId('level-up-icon').exists()).toBe(levelUpIconVisible); + }); + + it(`${arrowDownIconVisible ? 'render' : 'does not render'} arrow tail svg`, () => { + expect(wrapper.findByTestId('arrow-down-icon').exists()).toBe(arrowDownIconVisible); + }); + }, + ); + }); +}); diff --git a/spec/frontend/work_items_hierarchy/hierarchy_util_spec.js b/spec/frontend/work_items_hierarchy/hierarchy_util_spec.js new file mode 100644 index 00000000000..9042fa27d16 --- /dev/null +++ b/spec/frontend/work_items_hierarchy/hierarchy_util_spec.js @@ -0,0 +1,16 @@ +import { inferLicensePlan } from '~/work_items_hierarchy/hierarchy_util'; +import { LICENSE_PLAN } from '~/work_items_hierarchy/constants'; + +describe('inferLicensePlan', () => { + it.each` + epics | subEpics | licensePlan + ${true} | ${true} | ${LICENSE_PLAN.ULTIMATE} + ${true} | ${false} | ${LICENSE_PLAN.PREMIUM} + ${false} | ${false} | ${LICENSE_PLAN.FREE} + `( + 'returns $licensePlan when epic is $epics and sub-epic is $subEpics', + ({ epics, subEpics, licensePlan }) => { + expect(inferLicensePlan({ hasEpics: epics, hasSubEpics: subEpics })).toBe(licensePlan); + }, + ); +}); diff --git a/spec/lib/sidebars/concerns/work_item_hierarchy_spec.rb b/spec/lib/sidebars/concerns/work_item_hierarchy_spec.rb new file mode 100644 index 00000000000..f0a5e032764 --- /dev/null +++ b/spec/lib/sidebars/concerns/work_item_hierarchy_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Sidebars::Concerns::WorkItemHierarchy do + shared_examples 'hierarchy menu' do + let(:item_id) { :hierarchy } + + context 'when the feature is disabled does not render' do + before do + stub_feature_flags(work_items_hierarchy: false) + end + + specify { is_expected.to be_nil } + end + + context 'when the feature is enabled does render' do + before do + stub_feature_flags(work_items_hierarchy: true) + end + + specify { is_expected.not_to be_nil } + end + end + + describe 'Project hierarchy menu item' do + let_it_be_with_reload(:project) { create(:project, :repository) } + + let(:user) { project.owner } + let(:context) { Sidebars::Projects::Context.new(current_user: user, container: project) } + + subject { Sidebars::Projects::Menus::ProjectInformationMenu.new(context).renderable_items.index { |e| e.item_id == item_id } } + + it_behaves_like 'hierarchy menu' + end +end diff --git a/spec/lib/sidebars/projects/menus/project_information_menu_spec.rb b/spec/lib/sidebars/projects/menus/project_information_menu_spec.rb index 7e8d0ab0518..5cbf0a13b66 100644 --- a/spec/lib/sidebars/projects/menus/project_information_menu_spec.rb +++ b/spec/lib/sidebars/projects/menus/project_information_menu_spec.rb @@ -59,5 +59,25 @@ RSpec.describe Sidebars::Projects::Menus::ProjectInformationMenu do specify { is_expected.to be_nil } end end + + describe 'Hierarchy' do + let(:item_id) { :hierarchy } + + context 'when the feature is disabled' do + before do + stub_feature_flags(work_items_hierarchy: false) + end + + specify { is_expected.to be_nil } + end + + context 'when the feature is enabled' do + before do + stub_feature_flags(work_items_hierarchy: true) + end + + specify { is_expected.not_to be_nil } + end + end end end diff --git a/spec/migrations/cleanup_after_add_primary_email_to_emails_if_user_confirmed_spec.rb b/spec/migrations/cleanup_after_add_primary_email_to_emails_if_user_confirmed_spec.rb new file mode 100644 index 00000000000..abff7c6aba1 --- /dev/null +++ b/spec/migrations/cleanup_after_add_primary_email_to_emails_if_user_confirmed_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe CleanupAfterAddPrimaryEmailToEmailsIfUserConfirmed, :sidekiq do + let(:migration) { described_class.new } + let(:users) { table(:users) } + let(:emails) { table(:emails) } + + let!(:user_1) { users.create!(name: 'confirmed-user-1', email: 'confirmed-1@example.com', confirmed_at: 3.days.ago, projects_limit: 100) } + let!(:user_2) { users.create!(name: 'confirmed-user-2', email: 'confirmed-2@example.com', confirmed_at: 1.day.ago, projects_limit: 100) } + let!(:user_3) { users.create!(name: 'confirmed-user-3', email: 'confirmed-3@example.com', confirmed_at: 1.day.ago, projects_limit: 100) } + let!(:user_4) { users.create!(name: 'unconfirmed-user', email: 'unconfirmed@example.com', confirmed_at: nil, projects_limit: 100) } + + let!(:email_1) { emails.create!(email: 'confirmed-1@example.com', user_id: user_1.id, confirmed_at: 1.day.ago) } + let!(:email_2) { emails.create!(email: 'other_2@example.com', user_id: user_2.id, confirmed_at: 1.day.ago) } + + before do + stub_const("#{described_class.name}::BATCH_SIZE", 2) + end + + it 'consume any pending background migration job' do + expect_next_instance_of(Gitlab::BackgroundMigration::JobCoordinator) do |coordinator| + expect(coordinator).to receive(:steal).with('AddPrimaryEmailToEmailsIfUserConfirmed').twice + end + + migration.up + end + + it 'adds the primary email to emails for leftover confirmed users that do not have their primary email in the emails table', :aggregate_failures do + original_email_1_confirmed_at = email_1.reload.confirmed_at + + expect { migration.up }.to change { emails.count }.by(2) + + expect(emails.find_by(user_id: user_2.id, email: 'confirmed-2@example.com').confirmed_at).to eq(user_2.reload.confirmed_at) + expect(emails.find_by(user_id: user_3.id, email: 'confirmed-3@example.com').confirmed_at).to eq(user_3.reload.confirmed_at) + expect(email_1.reload.confirmed_at).to eq(original_email_1_confirmed_at) + + expect(emails.exists?(user_id: user_4.id)).to be(false) + end + + it 'continues in case of errors with one email' do + allow(Email).to receive(:create) { raise 'boom!' } + + expect { migration.up }.not_to raise_error + end +end diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb index 27967850389..576a8aa44fa 100644 --- a/spec/support/shared_contexts/navbar_structure_context.rb +++ b/spec/support/shared_contexts/navbar_structure_context.rb @@ -22,6 +22,7 @@ RSpec.shared_context 'project navbar structure' do nav_sub_items: [ _('Activity'), _('Labels'), + _('Planning hierarchy'), _('Members') ] }, diff --git a/spec/support/shared_contexts/policies/project_policy_shared_context.rb b/spec/support/shared_contexts/policies/project_policy_shared_context.rb index c39252cef13..aabd6454b88 100644 --- a/spec/support/shared_contexts/policies/project_policy_shared_context.rb +++ b/spec/support/shared_contexts/policies/project_policy_shared_context.rb @@ -17,7 +17,7 @@ RSpec.shared_context 'ProjectPolicy context' do %i[ award_emoji create_issue create_merge_request_in create_note create_project read_issue_board read_issue read_issue_iid read_issue_link - read_label read_issue_board_list read_milestone read_note read_project + read_label read_work_items_hierarchy read_issue_board_list read_milestone read_note read_project read_project_for_iids read_project_member read_release read_snippet read_wiki upload_file ] diff --git a/tooling/bin/find_changes b/tooling/bin/find_changes index c6b8bafbd85..8ad5011459b 100755 --- a/tooling/bin/find_changes +++ b/tooling/bin/find_changes @@ -48,7 +48,7 @@ class FindChanges # rubocop:disable Gitlab/NamespacedClass mr_changes = Gitlab.merge_request_changes(mr_project_path, mr_iid) - mr_changes.changes.map { |change| change['new_path'] } + mr_changes.changes.map { |change| change['new_path'] unless change['deleted_file'] }.compact end end