diff options
79 files changed, 1060 insertions, 137 deletions
diff --git a/.gitlab/issue_templates/Synchronous Database Index.md b/.gitlab/issue_templates/Synchronous Database Index.md new file mode 100644 index 00000000000..c61cf7abf0c --- /dev/null +++ b/.gitlab/issue_templates/Synchronous Database Index.md @@ -0,0 +1,11 @@ +<!-- Title suggestion: <async-index-name> synchronous database index(es) addition/removal --> + +## Summary + +This issue is to add a migration(s) to create/destroy the `<async-index-name>` database index(es) synchronously after it has been created/destroyed on GitLab.com. + +The asynchronous index(es) was introduced in <!-- Link to MR that introduced the asynchronous index -->. + +/assign me +/due in 2 weeks +/label ~database ~"type::maintenance" ~"maintenance::scalability" diff --git a/.rubocop_todo/gitlab/strong_memoize_attr.yml b/.rubocop_todo/gitlab/strong_memoize_attr.yml index e0a243aec8e..97e5db6567f 100644 --- a/.rubocop_todo/gitlab/strong_memoize_attr.yml +++ b/.rubocop_todo/gitlab/strong_memoize_attr.yml @@ -326,7 +326,6 @@ Gitlab/StrongMemoizeAttr: - 'ee/app/helpers/ee/trial_helper.rb' - 'ee/app/helpers/ee/welcome_helper.rb' - 'ee/app/helpers/license_monitoring_helper.rb' - - 'ee/app/helpers/paid_feature_callout_helper.rb' - 'ee/app/helpers/subscriptions_helper.rb' - 'ee/app/helpers/trial_status_widget_helper.rb' - 'ee/app/models/approval_merge_request_rule.rb' diff --git a/.rubocop_todo/layout/line_length.yml b/.rubocop_todo/layout/line_length.yml index 5cc1faaf78f..a77d44b1827 100644 --- a/.rubocop_todo/layout/line_length.yml +++ b/.rubocop_todo/layout/line_length.yml @@ -1912,7 +1912,6 @@ Layout/LineLength: - 'ee/spec/helpers/license_helper_spec.rb' - 'ee/spec/helpers/license_monitoring_helper_spec.rb' - 'ee/spec/helpers/notes_helper_spec.rb' - - 'ee/spec/helpers/paid_feature_callout_helper_spec.rb' - 'ee/spec/helpers/projects/on_demand_scans_helper_spec.rb' - 'ee/spec/helpers/projects/project_members_helper_spec.rb' - 'ee/spec/helpers/projects/security/dast_profiles_helper_spec.rb' diff --git a/.rubocop_todo/rspec/context_wording.yml b/.rubocop_todo/rspec/context_wording.yml index ab9031509c5..ae17e4d1f8d 100644 --- a/.rubocop_todo/rspec/context_wording.yml +++ b/.rubocop_todo/rspec/context_wording.yml @@ -284,7 +284,6 @@ RSpec/ContextWording: - 'ee/spec/helpers/groups/security_features_helper_spec.rb' - 'ee/spec/helpers/license_helper_spec.rb' - 'ee/spec/helpers/license_monitoring_helper_spec.rb' - - 'ee/spec/helpers/paid_feature_callout_helper_spec.rb' - 'ee/spec/helpers/projects/security/discover_helper_spec.rb' - 'ee/spec/helpers/projects_helper_spec.rb' - 'ee/spec/helpers/roadmaps_helper_spec.rb' diff --git a/.rubocop_todo/rspec/factory_bot/avoid_create.yml b/.rubocop_todo/rspec/factory_bot/avoid_create.yml index a857841f17a..0a4ea883cbe 100644 --- a/.rubocop_todo/rspec/factory_bot/avoid_create.yml +++ b/.rubocop_todo/rspec/factory_bot/avoid_create.yml @@ -66,7 +66,6 @@ RSpec/FactoryBot/AvoidCreate: - 'ee/spec/helpers/manual_quarterly_co_term_banner_helper_spec.rb' - 'ee/spec/helpers/markup_helper_spec.rb' - 'ee/spec/helpers/notes_helper_spec.rb' - - 'ee/spec/helpers/paid_feature_callout_helper_spec.rb' - 'ee/spec/helpers/path_locks_helper_spec.rb' - 'ee/spec/helpers/prevent_forking_helper_spec.rb' - 'ee/spec/helpers/projects/on_demand_scans_helper_spec.rb' diff --git a/.rubocop_todo/rspec/missing_feature_category.yml b/.rubocop_todo/rspec/missing_feature_category.yml index e5b0b67caab..3cc6a613303 100644 --- a/.rubocop_todo/rspec/missing_feature_category.yml +++ b/.rubocop_todo/rspec/missing_feature_category.yml @@ -625,7 +625,6 @@ RSpec/MissingFeatureCategory: - 'ee/spec/helpers/nav/new_dropdown_helper_spec.rb' - 'ee/spec/helpers/nav/top_nav_helper_spec.rb' - 'ee/spec/helpers/notes_helper_spec.rb' - - 'ee/spec/helpers/paid_feature_callout_helper_spec.rb' - 'ee/spec/helpers/path_locks_helper_spec.rb' - 'ee/spec/helpers/preferences_helper_spec.rb' - 'ee/spec/helpers/prevent_forking_helper_spec.rb' diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 6d84c4f035d..2dd600c5f52 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -ba63ee19fb2dafe6f2ca5bca5d12a0b24837ce17 +4e200026c03189abe2c60b071204340c4af5cf35 diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index a2bf6e67178..ff92cdd42c6 100644 --- a/app/assets/javascripts/releases/components/app_edit_new.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -13,6 +13,7 @@ import { isSameOriginUrl, getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue'; import { BACK_URL_PARAM } from '~/releases/constants'; +import { putCreateReleaseNotification } from '~/releases/release_notification_service'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import AssetLinksForm from './asset_links_form.vue'; import ConfirmDeleteModal from './confirm_delete_modal.vue'; @@ -49,6 +50,7 @@ export default { 'newMilestonePath', 'manageMilestonesPath', 'projectId', + 'projectPath', 'groupId', 'groupMilestonesAvailable', 'tagNotes', @@ -150,6 +152,7 @@ export default { submitForm() { if (!this.isFormSubmissionDisabled) { this.saveRelease(); + putCreateReleaseNotification(this.projectPath, this.release.name); } }, }, diff --git a/app/assets/javascripts/releases/components/app_show.vue b/app/assets/javascripts/releases/components/app_show.vue index 7147cfa01c8..544f2de5132 100644 --- a/app/assets/javascripts/releases/components/app_show.vue +++ b/app/assets/javascripts/releases/components/app_show.vue @@ -1,6 +1,7 @@ <script> import { createAlert } from '~/flash'; import { s__ } from '~/locale'; +import { popCreateReleaseNotification } from '~/releases/release_notification_service'; import oneReleaseQuery from '../graphql/queries/one_release.query.graphql'; import { convertGraphQLRelease } from '../util'; import ReleaseBlock from './release_block.vue'; @@ -49,6 +50,9 @@ export default { }, }, }, + mounted() { + popCreateReleaseNotification(this.fullPath); + }, methods: { showFlash(error) { createAlert({ diff --git a/app/assets/javascripts/releases/release_notification_service.js b/app/assets/javascripts/releases/release_notification_service.js new file mode 100644 index 00000000000..a4f926d7561 --- /dev/null +++ b/app/assets/javascripts/releases/release_notification_service.js @@ -0,0 +1,23 @@ +import { s__, sprintf } from '~/locale'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; + +const createReleaseSessionKey = (projectPath) => `createRelease:${projectPath}`; + +export const putCreateReleaseNotification = (projectPath, releaseName) => { + window.sessionStorage.setItem(createReleaseSessionKey(projectPath), releaseName); +}; + +export const popCreateReleaseNotification = (projectPath) => { + const key = createReleaseSessionKey(projectPath); + const createdRelease = window.sessionStorage.getItem(key); + + if (createdRelease) { + createAlert({ + message: sprintf(s__('Release|Release %{createdRelease} has been successfully created.'), { + createdRelease, + }), + variant: VARIANT_SUCCESS, + }); + window.sessionStorage.removeItem(key); + } +}; diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue index 7828efc358a..3ebd21609a6 100644 --- a/app/assets/javascripts/security_configuration/components/app.vue +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -4,6 +4,7 @@ import { __, s__ } from '~/locale'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; import SectionLayout from '~/vue_shared/security_configuration/components/section_layout.vue'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import AutoDevOpsAlert from './auto_dev_ops_alert.vue'; import AutoDevOpsEnabledAlert from './auto_dev_ops_enabled_alert.vue'; import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY } from './constants'; @@ -51,6 +52,7 @@ export default { UserCalloutDismisser, TrainingProviderList, }, + directives: { SafeHtml }, inject: ['projectFullPath', 'vulnerabilityTrainingDocsPath'], props: { augmentedSecurityFeatures: { @@ -143,7 +145,7 @@ export default { variant="danger" @dismiss="dismissAlert" > - {{ errorMessage }} + <span v-safe-html="errorMessage"></span> </gl-alert> <local-storage-sync v-model="autoDevopsEnabledAlertDismissedProjects" diff --git a/app/assets/javascripts/super_sidebar/components/counter.vue b/app/assets/javascripts/super_sidebar/components/counter.vue index d790e61ca31..62a1e5a6b20 100644 --- a/app/assets/javascripts/super_sidebar/components/counter.vue +++ b/app/assets/javascripts/super_sidebar/components/counter.vue @@ -40,9 +40,9 @@ export default { :is="component" :aria-label="ariaLabel" :href="href" - class="counter gl-relative gl-display-inline-block gl-flex-grow-1 gl-text-center gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-black-normal gl-border gl-border-gray-a-08 gl-font-sm gl-font-weight-bold" + class="counter gl-display-block gl-flex-grow-1 gl-text-center gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-gray-900 gl-border gl-border-gray-a-08 gl-font-sm gl-hover-text-gray-900 gl-hover-text-decoration-none" > <gl-icon aria-hidden="true" :name="icon" /> - <span aria-hidden="true">{{ count }}</span> + <span v-if="count" aria-hidden="true" class="gl-ml-1">{{ count }}</span> </component> </template> diff --git a/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue b/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue new file mode 100644 index 00000000000..edc13e305cf --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue @@ -0,0 +1,40 @@ +<script> +import { GlBadge, GlDisclosureDropdown } from '@gitlab/ui'; + +export default { + components: { + GlBadge, + GlDisclosureDropdown, + }, + props: { + items: { + type: Array, + required: true, + }, + }, + methods: { + navigate() { + this.$refs.link.click(); + }, + }, +}; +</script> + +<template> + <gl-disclosure-dropdown :items="items" placement="center" @action="navigate"> + <template #toggle> + <slot></slot> + </template> + <template #list-item="{ item }"> + <a + ref="link" + class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-hover-text-gray-900 gl-hover-text-decoration-none gl-text-gray-900" + :href="item.href" + tabindex="-1" + > + {{ item.text }} + <gl-badge pill size="sm" variant="neutral">{{ item.count || 0 }}</gl-badge> + </a> + </template> + </gl-disclosure-dropdown> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue index 22ef58eb302..ee72e8eafb4 100644 --- a/app/assets/javascripts/super_sidebar/components/user_bar.vue +++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue @@ -1,11 +1,12 @@ <script> -import { GlAvatar, GlDropdown, GlIcon } from '@gitlab/ui'; +import { GlAvatar, GlDropdown, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; import SafeHtml from '~/vue_shared/directives/safe_html'; import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; import logo from '../../../../views/shared/_logo.svg'; import CreateMenu from './create_menu.vue'; import Counter from './counter.vue'; +import MergeRequestMenu from './merge_request_menu.vue'; export default { logo, @@ -16,6 +17,7 @@ export default { CreateMenu, NewNavToggle, Counter, + MergeRequestMenu, }, i18n: { createNew: __('Create new...'), @@ -24,6 +26,7 @@ export default { todoList: __('To-Do list'), }, directives: { + GlTooltip: GlTooltipDirective, SafeHtml, }, inject: ['rootPath', 'toggleNewNavEndpoint'], @@ -55,17 +58,29 @@ export default { </div> <div class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2"> <counter + v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.issues" + class="gl-flex-basis-third" icon="issues" :count="sidebarData.assigned_open_issues_count" :href="sidebarData.issues_dashboard_path" :label="$options.i18n.issues" /> + <merge-request-menu + class="gl-flex-basis-third gl-display-block!" + :items="sidebarData.merge_request_menu" + > + <counter + v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.mergeRequests" + class="gl-w-full" + tabindex="-1" + icon="merge-request-open" + :count="sidebarData.total_merge_requests_count" + :label="$options.i18n.mergeRequests" + /> + </merge-request-menu> <counter - icon="merge-request-open" - :count="sidebarData.assigned_open_merge_requests_count" - :label="$options.i18n.mergeRequests" - /> - <counter + v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.todoList" + class="gl-flex-basis-third" icon="todo-done" :count="sidebarData.todos_pending_count" href="/dashboard/todos" diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss index 575fbc03f46..90313aee5b8 100644 --- a/app/assets/stylesheets/framework/super_sidebar.scss +++ b/app/assets/stylesheets/framework/super_sidebar.scss @@ -11,6 +11,24 @@ } } + .counter .gl-icon { + color: var(--gray-500, $gray-500); + } + + .counter:hover, + .counter:focus, + .gl-dropdown-custom-toggle:hover .counter, + .gl-dropdown-custom-toggle:focus .counter, + .gl-dropdown-custom-toggle[aria-expanded='true'] .counter { + background-color: $gray-50; + border-color: transparent; + mix-blend-mode: multiply; + + .gl-icon { + color: var(--gray-700, $gray-700); + } + } + .context-switcher-toggle { &[aria-expanded='true'] { background-color: $t-gray-a-08; diff --git a/app/controllers/concerns/analytics/cycle_analytics/value_stream_actions.rb b/app/controllers/concerns/analytics/cycle_analytics/value_stream_actions.rb new file mode 100644 index 00000000000..f10b23d1664 --- /dev/null +++ b/app/controllers/concerns/analytics/cycle_analytics/value_stream_actions.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Analytics + module CycleAnalytics + module ValueStreamActions + extend ActiveSupport::Concern + + included do + before_action :authorize + end + + def index + # FOSS users can only see the default value stream + value_streams = [Analytics::CycleAnalytics::ValueStream.build_default_value_stream(namespace)] + + render json: Analytics::CycleAnalytics::ValueStreamSerializer.new.represent(value_streams) + end + + private + + def namespace + raise NotImplementedError + end + + def authorize + authorize_read_cycle_analytics! + end + end + end +end + +Analytics::CycleAnalytics::ValueStreamActions.prepend_mod_with('Analytics::CycleAnalytics::ValueStreamActions') diff --git a/app/controllers/projects/analytics/cycle_analytics/value_streams_controller.rb b/app/controllers/projects/analytics/cycle_analytics/value_streams_controller.rb index 60bcd1d7238..f58730f1d33 100644 --- a/app/controllers/projects/analytics/cycle_analytics/value_streams_controller.rb +++ b/app/controllers/projects/analytics/cycle_analytics/value_streams_controller.rb @@ -1,17 +1,16 @@ # frozen_string_literal: true class Projects::Analytics::CycleAnalytics::ValueStreamsController < Projects::ApplicationController + include ::Analytics::CycleAnalytics::ValueStreamActions + respond_to :json feature_category :planning_analytics urgency :low - before_action :authorize_read_cycle_analytics! - - def index - # FOSS users can only see the default value stream - value_streams = [Analytics::CycleAnalytics::ProjectValueStream.build_default_value_stream(@project)] + private - render json: Analytics::CycleAnalytics::ValueStreamSerializer.new.represent(value_streams) + def namespace + project.project_namespace end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 718ca5a7b57..cfb596306b0 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -351,10 +351,20 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo private + # NOTE: Remove this disable with add_prepared_state_to_mr FF removal + # rubocop: disable Metrics/AbcSize def show_merge_request close_merge_request_if_no_source_project @merge_request.check_mergeability(async: true) + # NOTE: Remove the created_at check when removing the FF check + if ::Feature.enabled?(:add_prepared_state_to_mr, @merge_request.project) && + @merge_request.created_at < 5.minutes.ago && + !@merge_request.prepared? + + @merge_request.prepare + end + respond_to do |format| format.html do # use next to appease Rubocop @@ -396,6 +406,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end end end + # rubocop: enable Metrics/AbcSize def render_html_page preload_assignees_for_render(@merge_request) diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index a783990b33f..bbf45efa33e 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -17,6 +17,7 @@ module Resolvers before_connection_authorization do |nodes, current_user| projects = nodes.map(&:project) ::Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects, current_user).execute + ::Preloaders::GroupPolicyPreloader.new(projects.filter_map(&:group), current_user).execute end def ready?(**args) diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index def16887c53..2fe9ac7e155 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -636,6 +636,11 @@ module Types def sast_ci_configuration return unless Ability.allowed?(current_user, :read_code, object) + if project.repository.empty? + raise Gitlab::Graphql::Errors::MutationError, + _(format('You must %s before using Security features.', add_file_docs_link.html_safe)).html_safe + end + ::Security::CiConfiguration::SastParserService.new(object).configuration end @@ -654,6 +659,15 @@ module Types def project @project ||= object.respond_to?(:sync) ? object.sync : object end + + def add_file_docs_link + ActionController::Base.helpers.link_to _('add at least one file to the repository'), + Rails.application.routes.url_helpers.help_page_url( + 'user/project/repository/index.md', + anchor: 'add-files-to-a-repository'), + target: '_blank', + rel: 'noopener noreferrer' + end end end diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb index 2ca6a46906c..08306c53a4c 100644 --- a/app/helpers/sidebars_helper.rb +++ b/app/helpers/sidebars_helper.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true module SidebarsHelper + include MergeRequestsHelper include Nav::NewDropdownHelper def sidebar_tracking_attributes_by_object(object) @@ -39,10 +40,11 @@ module SidebarsHelper username: user.username, avatar_url: user.avatar_url, assigned_open_issues_count: user.assigned_open_issues_count, - assigned_open_merge_requests_count: user.assigned_open_merge_requests_count, todos_pending_count: user.todos_pending_count, issues_dashboard_path: issues_dashboard_path(assignee_username: user.username), + total_merge_requests_count: user_merge_requests_counts[:total], create_new_menu_groups: create_new_menu_groups(group: group, project: project), + merge_request_menu: create_merge_request_menu(user), support_path: support_url, display_whats_new: display_whats_new? } @@ -66,6 +68,26 @@ module SidebarsHelper end end + def create_merge_request_menu(user) + [ + { + name: _('Merge requests'), + items: [ + { + text: _('Assigned'), + href: merge_requests_dashboard_path(assignee_username: user.username), + count: user_merge_requests_counts[:assigned] + }, + { + text: _('Review requests'), + href: merge_requests_dashboard_path(reviewer_username: user.username), + count: user_merge_requests_counts[:review_requested] + } + ] + } + ] + end + def sidebar_attributes_for_object(object) case object when Project diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index a91c55e858b..3bc60ee1f8e 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -2021,6 +2021,14 @@ class MergeRequest < ApplicationRecord Feature.enabled?(:diffs_batch_cache_with_max_age, project) end + def prepared? + prepared_at.present? + end + + def prepare + NewMergeRequestWorker.perform_async(id, author_id) + end + private attr_accessor :skip_fetch_ref diff --git a/app/models/wiki_directory.rb b/app/models/wiki_directory.rb index 3a2613e15d9..f5d00013622 100644 --- a/app/models/wiki_directory.rb +++ b/app/models/wiki_directory.rb @@ -6,7 +6,7 @@ class WikiDirectory attr_accessor :slug, :entries validates :slug, presence: true - + alias_method :to_param, :slug # Groups a list of wiki pages into a nested collection of WikiPage and WikiDirectory objects, # preserving the order of the passed pages. # @@ -25,6 +25,7 @@ class WikiDirectory parent = File.dirname(path) parent = '' if parent == '.' directories[parent].entries << directory + directories[parent].entries.delete_if { |item| item.is_a?(WikiPage) && item.slug == directory.slug } end end end diff --git a/app/services/merge_requests/after_create_service.rb b/app/services/merge_requests/after_create_service.rb index 9e39aa94246..11251e56ee3 100644 --- a/app/services/merge_requests/after_create_service.rb +++ b/app/services/merge_requests/after_create_service.rb @@ -9,6 +9,8 @@ module MergeRequests prepare_for_mergeability(merge_request) prepare_merge_request(merge_request) + + mark_merge_request_as_prepared(merge_request) end private @@ -53,6 +55,10 @@ module MergeRequests merge_request.mark_as_unchecked merge_request.check_mergeability(async: true) end + + def mark_merge_request_as_prepared(merge_request) + merge_request.update!(prepared_at: Time.current) + end end end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index e057e6aba51..94cc4700a49 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -197,7 +197,7 @@ module Projects end def create_sast_commit - ::Security::CiConfiguration::SastCreateService.new(@project, current_user, {}, commit_on_default: true).execute + ::Security::CiConfiguration::SastCreateService.new(@project, current_user, { initialize_with_sast: true }, commit_on_default: true).execute end def readme_content diff --git a/app/services/security/ci_configuration/base_create_service.rb b/app/services/security/ci_configuration/base_create_service.rb index aaa850fde39..3e8865d3dff 100644 --- a/app/services/security/ci_configuration/base_create_service.rb +++ b/app/services/security/ci_configuration/base_create_service.rb @@ -12,6 +12,16 @@ module Security end def execute + if project.repository.empty? && !(@params && @params[:initialize_with_sast]) + docs_link = ActionController::Base.helpers.link_to _('add at least one file to the repository'), + Rails.application.routes.url_helpers.help_page_url('user/project/repository/index.md', + anchor: 'add-files-to-a-repository'), + target: '_blank', + rel: 'noopener noreferrer' + raise Gitlab::Graphql::Errors::MutationError, + _(format('You must %s before using Security features.', docs_link.html_safe)).html_safe + end + project.repository.add_branch(current_user, branch_name, project.default_branch) attributes_for_commit = attributes diff --git a/app/views/shared/wikis/_wiki_directory.html.haml b/app/views/shared/wikis/_wiki_directory.html.haml index a492d1e5aa0..a29e6ba7a85 100644 --- a/app/views/shared/wikis/_wiki_directory.html.haml +++ b/app/views/shared/wikis/_wiki_directory.html.haml @@ -1,4 +1,5 @@ -%li{ data: { qa_selector: 'wiki_directory_content' } } - = wiki_directory.title +%li{ class: active_when(params[:id] == wiki_directory.slug), data: { qa_selector: 'wiki_directory_content' } } + = link_to wiki_page_path(@wiki, wiki_directory), data: { qa_selector: 'wiki_dir_page_link', qa_page_name: wiki_directory.title } do + = wiki_directory.title %ul = render wiki_directory.entries, context: context diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index d1078c4bf92..e5d78aa9039 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -2935,7 +2935,7 @@ :urgency: :high :resource_boundary: :cpu :weight: 2 - :idempotent: false + :idempotent: true :tags: [] - :name: new_note :worker_name: NewNoteWorker diff --git a/app/workers/new_merge_request_worker.rb b/app/workers/new_merge_request_worker.rb index d6e8d517b5a..a32a414c0ba 100644 --- a/app/workers/new_merge_request_worker.rb +++ b/app/workers/new_merge_request_worker.rb @@ -8,6 +8,9 @@ class NewMergeRequestWorker # rubocop:disable Scalability/IdempotentWorker sidekiq_options retry: 3 include NewIssuable + idempotent! + deduplicate :until_executed + feature_category :code_review_workflow urgency :high worker_resource_boundary :cpu @@ -15,6 +18,7 @@ class NewMergeRequestWorker # rubocop:disable Scalability/IdempotentWorker def perform(merge_request_id, user_id) return unless objects_found?(merge_request_id, user_id) + return if issuable.prepared? MergeRequests::AfterCreateService .new(project: issuable.target_project, current_user: user) diff --git a/config/feature_flags/development/add_prepared_state_to_mr.yml b/config/feature_flags/development/add_prepared_state_to_mr.yml new file mode 100644 index 00000000000..49db6d92ae0 --- /dev/null +++ b/config/feature_flags/development/add_prepared_state_to_mr.yml @@ -0,0 +1,8 @@ +--- +name: add_prepared_state_to_mr +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/109967 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/389249 +milestone: '15.9' +type: development +group: group::code review +default_enabled: false diff --git a/config/initializers/memory_watchdog.rb b/config/initializers/memory_watchdog.rb index 99c5d61293f..27d9bf8b8f8 100644 --- a/config/initializers/memory_watchdog.rb +++ b/config/initializers/memory_watchdog.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true return unless Gitlab::Runtime.application? -return unless Gitlab::Utils.to_boolean(ENV['GITLAB_MEMORY_WATCHDOG_ENABLED'], default: Gitlab::Runtime.puma?) +return unless Gitlab::Utils.to_boolean(ENV['GITLAB_MEMORY_WATCHDOG_ENABLED'], default: true) Gitlab::Cluster::LifecycleEvents.on_worker_start do watchdog = Gitlab::Memory::Watchdog.new diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 58441b83c7d..cf8df814990 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -33,7 +33,7 @@ queues_config_hash[:namespace] = Gitlab::Redis::Queues::SIDEKIQ_NAMESPACE enable_json_logs = Gitlab.config.sidekiq.log_format == 'json' enable_sidekiq_memory_killer = ENV['SIDEKIQ_MEMORY_KILLER_MAX_RSS'].to_i.nonzero? && - !Gitlab::Utils.to_boolean(ENV['GITLAB_MEMORY_WATCHDOG_ENABLED']) + !Gitlab::Utils.to_boolean(ENV['GITLAB_MEMORY_WATCHDOG_ENABLED'], default: true) Sidekiq.configure_server do |config| config[:strict] = false diff --git a/db/migrate/20230125090315_add_prepared_at_to_merge_request.rb b/db/migrate/20230125090315_add_prepared_at_to_merge_request.rb new file mode 100644 index 00000000000..4e4b4ccf671 --- /dev/null +++ b/db/migrate/20230125090315_add_prepared_at_to_merge_request.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddPreparedAtToMergeRequest < Gitlab::Database::Migration[2.1] + disable_ddl_transaction! + + def up + with_lock_retries do + add_column :merge_requests, 'prepared_at', :datetime_with_timezone + end + end + + def down + with_lock_retries do + remove_column :merge_requests, 'prepared_at' + end + end +end diff --git a/db/migrate/20230130204743_remove_protected_environment_default_access_level.rb b/db/migrate/20230130204743_remove_protected_environment_default_access_level.rb new file mode 100644 index 00000000000..d01fd6b90f3 --- /dev/null +++ b/db/migrate/20230130204743_remove_protected_environment_default_access_level.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class RemoveProtectedEnvironmentDefaultAccessLevel < Gitlab::Database::Migration[2.1] + def change + change_column_default :protected_environment_deploy_access_levels, :access_level, from: 40, to: nil + end +end diff --git a/db/schema_migrations/20230125090315 b/db/schema_migrations/20230125090315 new file mode 100644 index 00000000000..aefe04923e7 --- /dev/null +++ b/db/schema_migrations/20230125090315 @@ -0,0 +1 @@ +37cc2c2eeb910333a45a18820a569d4263eb614bc138a6a0fe11d037bae045c3
\ No newline at end of file diff --git a/db/schema_migrations/20230130204743 b/db/schema_migrations/20230130204743 new file mode 100644 index 00000000000..dcb1725a6e2 --- /dev/null +++ b/db/schema_migrations/20230130204743 @@ -0,0 +1 @@ +3c6dd3b83bc6a1d9e94c93784e201d3e9114ef62070468a31abe9167ae111c35
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index a7e230d4cf1..6321bfa3ab0 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -17898,6 +17898,7 @@ CREATE TABLE merge_requests ( sprint_id bigint, merge_ref_sha bytea, draft boolean DEFAULT false NOT NULL, + prepared_at timestamp with time zone, CONSTRAINT check_970d272570 CHECK ((lock_version IS NOT NULL)) ); @@ -20968,7 +20969,7 @@ CREATE TABLE protected_environment_deploy_access_levels ( id integer NOT NULL, created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, - access_level integer DEFAULT 40, + access_level integer, protected_environment_id integer NOT NULL, user_id integer, group_id integer, diff --git a/doc/administration/inactive_project_deletion.md b/doc/administration/inactive_project_deletion.md index ea5658bef84..ed75373448e 100644 --- a/doc/administration/inactive_project_deletion.md +++ b/doc/administration/inactive_project_deletion.md @@ -8,70 +8,52 @@ info: To determine the technical writer assigned to the Stage/Group associated w > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/85689) in GitLab 15.0 [with a flag](../administration/feature_flags.md) named `inactive_projects_deletion`. Disabled by default. > - [Feature flag `inactive_projects_deletion`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/96803) removed in GitLab 15.4. +> - Configuration through GitLab UI [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/85575) in GitLab 15.1. Administrators of large GitLab instances can find that over time, projects become inactive and are no longer used. -These projects take up unnecessary disk space. With inactive project deletion, you can identify these projects, warn -the maintainers ahead of time, and then delete the projects if they remain inactive. When an inactive project is -deleted, the action generates an audit event that it was performed by the @GitLab-Admin-Bot. +These projects take up unnecessary disk space. + +With inactive project deletion, you can identify these projects, warn the maintainers ahead of time, and then delete the +projects if they remain inactive. When an inactive project is deleted, the action generates an audit event that it was +performed by the @GitLab-Admin-Bot. For the default setting on GitLab.com, see the [GitLab.com settings page](../user/gitlab_com/index.md#inactive-project-deletion). ## Configure inactive project deletion -You can configure inactive projects deletion or turn it off using either: - -- [The GitLab API](#using-the-api) (GitLab 15.0 and later). -- [The GitLab UI](#using-the-gitlab-ui) (GitLab 15.1 and later). - -The following options are available: - -- **Delete inactive projects** (`delete_inactive_projects`): Enable or disable inactive project deletion. -- **Delete inactive projects that exceed** (`inactive_projects_min_size_mb`): Minimum size (MB) of inactive projects to - be considered for deletion. Projects smaller in size than this threshold aren't considered inactive. -- **Delete project after** (`inactive_projects_delete_after_months`): Minimum duration (months) after which a project is - scheduled for deletion if it continues be inactive. -- **Send warning email** (`inactive_projects_send_warning_email_after_months`): Minimum duration (months) after which a - deletion warning email is sent if a project continues to be inactive. The warning email is sent to users with the - Owner and Maintainer roles of the inactive project. This duration must be less than the - **Delete project after** (`inactive_projects_delete_after_months`) duration. +To configure deletion of inactive projects: -For example (using the API): - -- `delete_inactive_projects` enabled. -- `inactive_projects_min_size_mb` set to `50`. -- `inactive_projects_delete_after_months` set to `12`. -- `inactive_projects_send_warning_email_after_months` set to `6`. - -In this scenario, when a project's size is: - -- Less than 50 MB, the project is not considered inactive. -- Greater than 50 MB and it is inactive for: - - More than 6 months, a deletion warning is email is sent to users with the Owner and Maintainer role on the project - with the scheduled date of deletion. - - More than 12 months, the project is scheduled for deletion. +1. On the top bar, select **Main menu > Admin**. +1. On the left sidebar, select **Settings > Repository**. +1. Expand **Repository maintenance**. +1. In the **Inactive project deletion** section, select **Delete inactive projects**. +1. Configure the settings. + - The warning email is sent to users who have the Owner and Maintainer role for the inactive project. + - The email duration must be less than the **Delete project after** duration. +1. Select **Save changes**. -### Using the API +### Configuration example -You can use the [Application settings API](../api/settings.md#change-application-settings) to configure inactive projects. +If you use these settings: -### Using the GitLab UI +- **Delete inactive projects** enabled. +- **Delete inactive projects that exceed** set to `50`. +- **Delete project after** set to `12`. +- **Send warning email** set to `6`. -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/85575) in GitLab 15.1. +If a project is less than 50 MB, the project is not considered inactive. -To configure inactive projects with the GitLab UI: +If a project is more than 50 MB and it is inactive for: -1. On the top bar, select **Main menu > Admin**. -1. On the left sidebar, select **Settings > Repository**. -1. Expand **Repository maintenance**. -1. In the **Inactive project deletion** section, configure the necessary options. -1. Select **Save changes**. +- More than 6 months: A deletion warning email is sent. This mail includes the date that the project will be deleted. +- More than 12 months: The project is scheduled for deletion. ## Determine when a project was last active You can view a project's activities and determine when the project was last active in the following ways: -1. Go to the [activity page](../user/project/working_with_projects.md#view-project-activity) for the project and view - the date of the latest event. -1. View the `last_activity_at` attribute for the project using the [Projects API](../api/projects.md). -1. List the visible events for the project using the [Events API](../api/events.md#list-a-projects-visible-events). - View the `created_at` attribute of the latest event. +- Go to the [activity page](../user/project/working_with_projects.md#view-project-activity) for the project and view + the date of the latest event. +- View the `last_activity_at` attribute for the project using the [Projects API](../api/projects.md). +- List the visible events for the project using the [Events API](../api/events.md#list-a-projects-visible-events). + View the `created_at` attribute of the latest event. diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 419175f6553..994246b64e4 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -2836,6 +2836,7 @@ Input type: `EpicMoveListInput` | <a id="mutationepicmovelistfromlistid"></a>`fromListId` | [`BoardsEpicListID`](#boardsepiclistid) | ID of the board list that the epic will be moved from. Required if moving between lists. | | <a id="mutationepicmovelistmoveafterid"></a>`moveAfterId` | [`EpicID`](#epicid) | ID of epic that should be placed after the current epic. | | <a id="mutationepicmovelistmovebeforeid"></a>`moveBeforeId` | [`EpicID`](#epicid) | ID of epic that should be placed before the current epic. | +| <a id="mutationepicmovelistpositioninlist"></a>`positionInList` | [`Int`](#int) | Position of epics within the board list. Positions start at 0. Use -1 to move to the end of the list. | | <a id="mutationepicmovelisttolistid"></a>`toListId` | [`BoardsEpicListID!`](#boardsepiclistid) | ID of the list the epic will be in after mutation. | #### Fields diff --git a/doc/api/group_epic_boards.md b/doc/api/group_epic_boards.md new file mode 100644 index 00000000000..93be9431874 --- /dev/null +++ b/doc/api/group_epic_boards.md @@ -0,0 +1,171 @@ +--- +stage: Plan +group: Product Planning +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments +--- + +# Group epic boards API **(PREMIUM)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/385903) in GitLab 15.9. + +Every API call to [group epic boards](../user/group/epics/epic_boards.md#epic-boards) must be authenticated. + +If a user is not a member of a group and the group is private, a `GET` +request results in `404` status code. + +## List all epic boards in a group + +Lists epic boards in the given group. + +```plaintext +GET /groups/:id/epic_boards +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](rest/index.md#namespaced-path-encoding) accessible by the authenticated user | + +```shell +curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/5/epic_boards" +``` + +Example response: + +```json +[ + { + "id": 1, + "name": "group epic board", + "group": { + "id": 5, + "name": "Documentcloud", + "web_url": "http://example.com/groups/documentcloud" + }, + "hide_backlog_list": false, + "hide_closed_list": false, + "labels": [ + { + "id": 1, + "title": "Board Label", + "color": "#c21e56", + "description": "label applied to the epic board", + "group_id": 5, + "project_id": null, + "template": false, + "text_color": "#FFFFFF", + "created_at": "2023-01-27T10:40:59.738Z", + "updated_at": "2023-01-27T10:40:59.738Z" + } + ], + "lists": [ + { + "id": 1, + "label": { + "id": 69, + "name": "Testing", + "color": "#F0AD4E", + "description": null + }, + "position": 1 + }, + { + "id": 2, + "label": { + "id": 70, + "name": "Ready", + "color": "#FF0000", + "description": null + }, + "position": 2 + }, + { + "id": 3, + "label": { + "id": 71, + "name": "Production", + "color": "#FF5F00", + "description": null + }, + "position": 3 + } + ] + } +] +``` + +## Single group epic board + +Gets a single group epic board. + +```plaintext +GET /groups/:id/epic_boards/:board_id +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](rest/index.md#namespaced-path-encoding) accessible by the authenticated user | +| `board_id` | integer | yes | The ID of an epic board | + +```shell +curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/5/epic_boards/1" +``` + +Example response: + +```json + { + "id": 1, + "name": "group epic board", + "group": { + "id": 5, + "name": "Documentcloud", + "web_url": "http://example.com/groups/documentcloud" + }, + "labels": [ + { + "id": 1, + "title": "Board Label", + "color": "#c21e56", + "description": "label applied to the epic board", + "group_id": 5, + "project_id": null, + "template": false, + "text_color": "#FFFFFF", + "created_at": "2023-01-27T10:40:59.738Z", + "updated_at": "2023-01-27T10:40:59.738Z" + } + ], + "lists" : [ + { + "id" : 1, + "label" : { + "id": 69, + "name" : "Testing", + "color" : "#F0AD4E", + "description" : null + }, + "position" : 1 + }, + { + "id" : 2, + "label" : { + "id": 70, + "name" : "Ready", + "color" : "#FF0000", + "description" : null + }, + "position" : 2 + }, + { + "id" : 3, + "label" : { + "id": 71, + "name" : "Production", + "color" : "#FF5F00", + "description" : null + }, + "position" : 3 + } + ] + } +``` diff --git a/doc/api/settings.md b/doc/api/settings.md index 74dcf5b8fda..624aff7ff54 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -307,7 +307,6 @@ listed in the descriptions of the relevant settings. | `default_snippet_visibility` | string | no | What visibility level new snippets receive. Can take `private`, `internal` and `public` as a parameter. Default is `private`. | | `delayed_project_deletion` **(PREMIUM SELF)** | boolean | no | Enable delayed project deletion by default in new groups. Default is `false`. [From GitLab 15.1](https://gitlab.com/gitlab-org/gitlab/-/issues/352960), can only be enabled when `delayed_group_deletion` is true. | | `delayed_group_deletion` **(PREMIUM SELF)** | boolean | no | Enable delayed group deletion. Default is `true`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/352959) in GitLab 15.0. [From GitLab 15.1](https://gitlab.com/gitlab-org/gitlab/-/issues/352960), disables and locks the group-level setting for delayed protect deletion when set to `false`. | -| `delete_inactive_projects` | boolean | no | Enable inactive project deletion feature. Default is `false`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/84519) in GitLab 14.10. [Became operational without feature flag](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/96803) in GitLab 15.4. | | `deletion_adjourned_period` **(PREMIUM SELF)** | integer | no | The number of days to wait before deleting a project or group that is marked for deletion. Value must be between `1` and `90`. Defaults to `7`. [From GitLab 15.1](https://gitlab.com/gitlab-org/gitlab/-/issues/352960), a hook on `deletion_adjourned_period` sets the period to `1` on every update, and sets both `delayed_project_deletion` and `delayed_group_deletion` to `false` if the period is `0`. | | `diff_max_patch_bytes` | integer | no | Maximum [diff patch size](../user/admin_area/diff_limits.md), in bytes. | | `diff_max_files` | integer | no | Maximum [files in a diff](../user/admin_area/diff_limits.md). | @@ -389,9 +388,6 @@ listed in the descriptions of the relevant settings. | `html_emails_enabled` | boolean | no | Enable HTML emails. | | `import_sources` | array of strings | no | Sources to allow project import from, possible values: `github`, `bitbucket`, `bitbucket_server`, `gitlab`, `fogbugz`, `git`, `gitlab_project`, `gitea`, `manifest`, and `phabricator`. | | `in_product_marketing_emails_enabled` | boolean | no | Enable [in-product marketing emails](../user/profile/notifications.md#global-notification-settings). Enabled by default. | -| `inactive_projects_delete_after_months` | integer | no | If `delete_inactive_projects` is `true`, the time (in months) to wait before deleting inactive projects. Default is `2`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/84519) in GitLab 14.10. [Became operational](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/85689) in GitLab 15.0. | -| `inactive_projects_min_size_mb` | integer | no | If `delete_inactive_projects` is `true`, the minimum repository size for projects to be checked for inactivity. Default is `0`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/84519) in GitLab 14.10. [Became operational](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/85689) in GitLab 15.0. | -| `inactive_projects_send_warning_email_after_months` | integer | no | If `delete_inactive_projects` is `true`, sets the time (in months) to wait before emailing maintainers that the project is scheduled be deleted because it is inactive. Default is `1`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/84519) in GitLab 14.10. [Became operational](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/85689) in GitLab 15.0. | | `invisible_captcha_enabled` | boolean | no | Enable Invisible CAPTCHA spam detection during sign-up. Disabled by default. | | `issues_create_limit` | integer | no | Max number of issue creation requests per minute per user. Disabled by default.| | `keep_latest_artifact` | boolean | no | Prevent the deletion of the artifacts from the most recent successful jobs, regardless of the expiry time. Enabled by default. | @@ -526,6 +522,17 @@ listed in the descriptions of the relevant settings. | `jira_connect_application_key` | String | no | Application ID of the OAuth application that should be used to authenticate with the GitLab for Jira Cloud app | | `jira_connect_proxy_url` | String | no | URL of the GitLab instance that should be used as a proxy for the GitLab for Jira Cloud app | +### Configure inactive project deletion + +You can configure inactive projects deletion or turn it off. + +| Attribute | Type | Required | Description | +|------------------------------------------|------------------|:------------------------------------:|-------------| +| `delete_inactive_projects` | boolean | no | Enable [inactive project deletion](../administration/inactive_project_deletion.md). Default is `false`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/84519) in GitLab 14.10. [Became operational without feature flag](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/96803) in GitLab 15.4. | +| `inactive_projects_delete_after_months` | integer | no | If `delete_inactive_projects` is `true`, the time (in months) to wait before deleting inactive projects. Default is `2`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/84519) in GitLab 14.10. [Became operational](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/85689) in GitLab 15.0. | +| `inactive_projects_min_size_mb` | integer | no | If `delete_inactive_projects` is `true`, the minimum repository size for projects to be checked for inactivity. Default is `0`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/84519) in GitLab 14.10. [Became operational](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/85689) in GitLab 15.0. | +| `inactive_projects_send_warning_email_after_months` | integer | no | If `delete_inactive_projects` is `true`, sets the time (in months) to wait before emailing maintainers that the project is scheduled be deleted because it is inactive. Default is `1`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/84519) in GitLab 14.10. [Became operational](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/85689) in GitLab 15.0. | + ## Housekeeping fields ::Tabs diff --git a/doc/development/database/adding_database_indexes.md b/doc/development/database/adding_database_indexes.md index d909f66d6c8..1e3a1de9b69 100644 --- a/doc/development/database/adding_database_indexes.md +++ b/doc/development/database/adding_database_indexes.md @@ -310,8 +310,13 @@ index creation can proceed at a lower level of risk. ### Schedule the index to be created -Create an MR with a post-deployment migration which prepares the index -for asynchronous creation. An example of creating an index using +1. Create a merge request containing a post-deployment migration, which prepares + the index for asynchronous creation. +1. [Create a follow-up issue](https://gitlab.com/gitlab-org/gitlab/-/issues/new?issuable_template=Synchronous%20Database%20Index) + to add a migration that creates the index synchronously. +1. In the merge request that prepares the asynchronous index, add a comment mentioning the follow-up issue. + +An example of creating an index using the asynchronous index helpers can be seen in the block below. This migration enters the index name and definition into the `postgres_async_indexes` table. The process that runs on weekends pulls indexes from this @@ -322,6 +327,7 @@ table and attempt to create them. INDEX_NAME = 'index_ci_builds_on_some_column' +# TODO: Index to be created synchronously in https://gitlab.com/gitlab-org/gitlab/-/issues/XXXXX def up prepare_async_index :ci_builds, :some_column, name: INDEX_NAME end @@ -405,8 +411,13 @@ index destruction can proceed at a lower level of risk. ### Schedule the index to be removed -Create an MR with a post-deployment migration which prepares the index -for asynchronous destruction. For example. to destroy an index using +1. Create a merge request containing a post-deployment migration, which prepares + the index for asynchronous destruction. +1. [Create a follow-up issue](https://gitlab.com/gitlab-org/gitlab/-/issues/new?issuable_template=Synchronous%20Database%20Index) + to add a migration that destroys the index synchronously. +1. In the merge request that prepares the asynchronous index removal, add a comment mentioning the follow-up issue. + +For example, to destroy an index using the asynchronous index helpers: ```ruby @@ -414,6 +425,7 @@ the asynchronous index helpers: INDEX_NAME = 'index_ci_builds_on_some_column' +# TODO: Index to be destroyed synchronously in https://gitlab.com/gitlab-org/gitlab/-/issues/XXXXX def up prepare_async_index_removal :ci_builds, :some_column, name: INDEX_NAME end diff --git a/doc/user/project/merge_requests/reviews/img/reviewer_approval_rules_form_v13_8.png b/doc/user/project/merge_requests/reviews/img/reviewer_approval_rules_form_v13_8.png Binary files differdeleted file mode 100644 index c2aa0689d65..00000000000 --- a/doc/user/project/merge_requests/reviews/img/reviewer_approval_rules_form_v13_8.png +++ /dev/null diff --git a/doc/user/project/merge_requests/reviews/img/reviewer_approval_rules_form_v15_9.png b/doc/user/project/merge_requests/reviews/img/reviewer_approval_rules_form_v15_9.png Binary files differnew file mode 100644 index 00000000000..6839c675625 --- /dev/null +++ b/doc/user/project/merge_requests/reviews/img/reviewer_approval_rules_form_v15_9.png diff --git a/doc/user/project/merge_requests/reviews/img/reviewer_approval_rules_sidebar_v13_8.png b/doc/user/project/merge_requests/reviews/img/reviewer_approval_rules_sidebar_v13_8.png Binary files differdeleted file mode 100644 index 3828868965b..00000000000 --- a/doc/user/project/merge_requests/reviews/img/reviewer_approval_rules_sidebar_v13_8.png +++ /dev/null diff --git a/doc/user/project/merge_requests/reviews/img/reviewer_approval_rules_sidebar_v15_9.png b/doc/user/project/merge_requests/reviews/img/reviewer_approval_rules_sidebar_v15_9.png Binary files differnew file mode 100644 index 00000000000..c7942d1e36d --- /dev/null +++ b/doc/user/project/merge_requests/reviews/img/reviewer_approval_rules_sidebar_v15_9.png diff --git a/doc/user/project/merge_requests/reviews/img/suggested_reviewers_v15_4.png b/doc/user/project/merge_requests/reviews/img/suggested_reviewers_v15_4.png Binary files differdeleted file mode 100644 index aae75b0736c..00000000000 --- a/doc/user/project/merge_requests/reviews/img/suggested_reviewers_v15_4.png +++ /dev/null diff --git a/doc/user/project/merge_requests/reviews/img/suggested_reviewers_v15_9.png b/doc/user/project/merge_requests/reviews/img/suggested_reviewers_v15_9.png Binary files differnew file mode 100644 index 00000000000..80083e1819e --- /dev/null +++ b/doc/user/project/merge_requests/reviews/img/suggested_reviewers_v15_9.png diff --git a/doc/user/project/merge_requests/reviews/index.md b/doc/user/project/merge_requests/reviews/index.md index bf25c7ef9bb..9a390364466 100644 --- a/doc/user/project/merge_requests/reviews/index.md +++ b/doc/user/project/merge_requests/reviews/index.md @@ -25,9 +25,9 @@ review merge requests in Visual Studio Code. > [Introduced](https://gitlab.com/groups/gitlab-org/modelops/applied-ml/review-recommender/-/epics/3) in GitLab 15.4. -GitLab can recommend reviewers with Suggested Reviewers. Using the changes in a merge request and a project's contribution graph, machine learning powered suggestions appear in the reviewer section of the right merge request sidebar. +GitLab can suggest reviewers. Using the changes in a merge request and a project's contribution graph, machine learning suggestions appear in the reviewer section of the right sidebar. -![Suggested Reviewers](img/suggested_reviewers_v15_4.png) +![Suggested Reviewers](img/suggested_reviewers_v15_9.png) This feature is currently in [Open Beta](https://about.gitlab.com/handbook/product/gitlab-the-product/#open-beta) behind a [feature flag](https://gitlab.com/gitlab-org/gitlab/-/issues/368356). @@ -176,11 +176,11 @@ below the name of each suggested reviewer. [Code Owners](../../code_owners.md) a This example shows reviewers and approval rules when creating a new merge request: -![Reviewer approval rules in new/edit form](img/reviewer_approval_rules_form_v13_8.png) +![Reviewer approval rules in new/edit form](img/reviewer_approval_rules_form_v15_9.png) This example shows reviewers and approval rules in a merge request sidebar: -![Reviewer approval rules in sidebar](img/reviewer_approval_rules_sidebar_v13_8.png) +![Reviewer approval rules in sidebar](img/reviewer_approval_rules_sidebar_v15_9.png) ### Request a new review diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 05c1f491aee..b63d583f3d6 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5540,6 +5540,9 @@ msgid_plural "%d Assignees" msgstr[0] "" msgstr[1] "" +msgid "Assignee (optional)" +msgstr "" + msgid "Assignee has no permissions" msgstr "" @@ -35109,6 +35112,9 @@ msgstr "" msgid "Release|More information" msgstr "" +msgid "Release|Release %{createdRelease} has been successfully created." +msgstr "" + msgid "Release|Releases are based on Git tags and mark specific points in a project's development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software." msgstr "" @@ -46370,9 +46376,6 @@ msgstr "" msgid "ValueStreamEvent|Stop" msgstr "" -msgid "ValueStream|The Default Value Stream cannot be deleted" -msgstr "" - msgid "Variable" msgstr "" @@ -49304,6 +49307,9 @@ msgstr[1] "" msgid "access:" msgstr "" +msgid "add at least one file to the repository" +msgstr "" + msgid "added %{emails}" msgstr "" diff --git a/qa/qa/page/component/wiki_sidebar.rb b/qa/qa/page/component/wiki_sidebar.rb index dfb912a1d0b..7543b9655f9 100644 --- a/qa/qa/page/component/wiki_sidebar.rb +++ b/qa/qa/page/component/wiki_sidebar.rb @@ -20,6 +20,7 @@ module QA base.view 'app/views/shared/wikis/_wiki_directory.html.haml' do element :wiki_directory_content + element :wiki_dir_page_link end end @@ -42,6 +43,10 @@ module QA def has_directory?(directory) has_element?(:wiki_directory_content, text: directory) end + + def has_dir_page?(dir_page) + has_element?(:wiki_dir_page_link, page_name: dir_page) + end end end end diff --git a/qa/qa/resource/api_fabricator.rb b/qa/qa/resource/api_fabricator.rb index 76a1be647c3..d7a220bc83f 100644 --- a/qa/qa/resource/api_fabricator.rb +++ b/qa/qa/resource/api_fabricator.rb @@ -19,6 +19,7 @@ module QA (respond_to?(:api_put_path) && respond_to?(:api_put_body)) end + # @return [String] the resource web url def fabricate_via_api! unless api_support? raise NotImplementedError, "Resource #{self.class.name} does not support fabrication via the API!" diff --git a/qa/qa/resource/project_web_hook.rb b/qa/qa/resource/project_web_hook.rb index 8b806c42030..86e662932e1 100644 --- a/qa/qa/resource/project_web_hook.rb +++ b/qa/qa/resource/project_web_hook.rb @@ -16,7 +16,11 @@ module QA confidential_note ].freeze - attr_accessor :url, :enable_ssl, :id + attr_accessor :url, :enable_ssl + + attribute :disabled_until + attribute :id + attribute :alert_status attribute :project do Project.fabricate_via_api! do |resource| @@ -33,19 +37,28 @@ module QA def initialize @id = nil @enable_ssl = false + @alert_status = nil @url = nil end + def fabricate_via_api! + resource_web_url = super + + @id = api_response[:id] + + resource_web_url + end + def resource_web_url(resource) "/project/#{project.name}/~/hooks/##{resource[:id]}/edit" end def api_get_path - "/projects/#{project.id}/hooks" + "#{api_post_path}/#{api_response[:id]}" end def api_post_path - api_get_path + "/projects/#{project.id}/hooks" end def api_post_body diff --git a/qa/qa/specs/features/api/1_manage/integrations/webhook_events_spec.rb b/qa/qa/specs/features/api/1_manage/integrations/webhook_events_spec.rb index fb530967073..8439b881ed7 100644 --- a/qa/qa/specs/features/api/1_manage/integrations/webhook_events_spec.rb +++ b/qa/qa/specs/features/api/1_manage/integrations/webhook_events_spec.rb @@ -125,11 +125,48 @@ module QA end end + context 'when hook fails' do + let(:fail_mock) do + <<~YAML + - request: + method: POST + path: /default + response: + status: 404 + headers: + Content-Type: text/plain + body: 'webhook failed' + YAML + end + + let(:hook_trigger_times) { 5 } + let(:disabled_after) { 4 } + + it 'hook is auto-disabled', + testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/389595' do + setup_webhook(fail_mock, issues: true) do |webhook, smocker| + hook_trigger_times.times do + Resource::Issue.fabricate_via_api! do |issue_init| + issue_init.project = webhook.project + end + end + + expect { smocker.history(session).size }.to eventually_eq(disabled_after) + .within(max_duration: 30, sleep_interval: 2), + -> { "Should have #{disabled_after} events, got: #{smocker.history(session).size}" } + + webhook.reload! + + expect(webhook.alert_status).to eql('disabled') + end + end + end + private - def setup_webhook(**event_args) + def setup_webhook(mock = Vendor::Smocker::SmockerApi::DEFAULT_MOCK, **event_args) Service::DockerRun::Smocker.init(wait: 10) do |smocker| - smocker.register(session: session) + smocker.register(mock, session: session) webhook = Resource::ProjectWebHook.fabricate_via_api! do |hook| hook.url = smocker.url diff --git a/spec/controllers/concerns/analytics/cycle_analytics/value_stream_actions_spec.rb b/spec/controllers/concerns/analytics/cycle_analytics/value_stream_actions_spec.rb new file mode 100644 index 00000000000..246119a8118 --- /dev/null +++ b/spec/controllers/concerns/analytics/cycle_analytics/value_stream_actions_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Analytics::CycleAnalytics::ValueStreamActions, type: :controller, +feature_category: :planning_analytics do + subject(:controller) do + Class.new(ApplicationController) do + include Analytics::CycleAnalytics::ValueStreamActions + + def call_namespace + namespace + end + end + end + + describe '#namespace' do + it 'raises NotImplementedError' do + expect { controller.new.call_namespace }.to raise_error(NotImplementedError) + end + end +end diff --git a/spec/controllers/projects/analytics/cycle_analytics/value_streams_controller_spec.rb b/spec/controllers/projects/analytics/cycle_analytics/value_streams_controller_spec.rb index 5b434eb2011..a3a86138f18 100644 --- a/spec/controllers/projects/analytics/cycle_analytics/value_streams_controller_spec.rb +++ b/spec/controllers/projects/analytics/cycle_analytics/value_streams_controller_spec.rb @@ -30,6 +30,20 @@ RSpec.describe Projects::Analytics::CycleAnalytics::ValueStreamsController do expect(json_response.first['name']).to eq('default') end + + # testing the authorize method within ValueStreamActions + context 'when issues and merge requests are disabled' do + it 'renders 404' do + project.project_feature.update!( + issues_access_level: ProjectFeature::DISABLED, + merge_requests_access_level: ProjectFeature::DISABLED + ) + + get :index, params: params + + expect(response).to have_gitlab_http_status(:not_found) + end + end end context 'when user is not member of the project' do diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 86ca4d3bf1f..ceb3f803db5 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -68,6 +68,72 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :code_review end end + context 'when add_prepared_state_to_mr feature flag on' do + before do + stub_feature_flags(add_prepared_state_to_mr: true) + end + + context 'when the merge request is not prepared' do + before do + merge_request.update!(prepared_at: nil, created_at: 10.minutes.ago) + end + + it 'prepares the merge request' do + expect(NewMergeRequestWorker).to receive(:perform_async) + + go + end + + context 'when the merge request was created less than 5 minutes ago' do + it 'does not prepare the merge request again' do + travel_to(4.minutes.from_now) do + merge_request.update!(created_at: Time.current - 4.minutes) + + expect(NewMergeRequestWorker).not_to receive(:perform_async) + + go + end + end + end + + context 'when the merge request was created 5 minutes ago' do + it 'prepares the merge request' do + travel_to(6.minutes.from_now) do + merge_request.update!(created_at: Time.current - 6.minutes) + + expect(NewMergeRequestWorker).to receive(:perform_async) + + go + end + end + end + end + + context 'when the merge request is prepared' do + before do + merge_request.update!(prepared_at: Time.current, created_at: 10.minutes.ago) + end + + it 'prepares the merge request' do + expect(NewMergeRequestWorker).not_to receive(:perform_async) + + go + end + end + end + + context 'when add_prepared_state_to_mr feature flag is off' do + before do + stub_feature_flags(add_prepared_state_to_mr: false) + end + + it 'does not prepare the merge request again' do + expect(NewMergeRequestWorker).not_to receive(:perform_async) + + go + end + end + describe 'as html' do it 'sets the endpoint_metadata_url' do go diff --git a/spec/frontend/releases/components/app_edit_new_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js index 5c4eb6912c8..d7d8e634569 100644 --- a/spec/frontend/releases/components/app_edit_new_spec.js +++ b/spec/frontend/releases/components/app_edit_new_spec.js @@ -10,6 +10,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import ReleaseEditNewApp from '~/releases/components/app_edit_new.vue'; +import { putCreateReleaseNotification } from '~/releases/release_notification_service'; import AssetLinksForm from '~/releases/components/asset_links_form.vue'; import ConfirmDeleteModal from '~/releases/components/confirm_delete_modal.vue'; import { BACK_URL_PARAM } from '~/releases/constants'; @@ -19,6 +20,8 @@ const originalRelease = originalOneReleaseForEditingQueryResponse.data.project.r const originalMilestones = originalRelease.milestones; const releasesPagePath = 'path/to/releases/page'; const upcomingReleaseDocsPath = 'path/to/upcoming/release/docs'; +const projectPath = 'project/path'; +jest.mock('~/releases/release_notification_service'); describe('Release edit/new component', () => { let wrapper; @@ -32,6 +35,7 @@ describe('Release edit/new component', () => { state = { release, isExistingRelease: true, + projectPath, markdownDocsPath: 'path/to/markdown/docs', releasesPagePath, projectId: '8', @@ -163,6 +167,13 @@ describe('Release edit/new component', () => { expect(actions.saveRelease).toHaveBeenCalledTimes(1); }); + + it('sets release created notification when the form is submitted', () => { + findForm().trigger('submit'); + const releaseName = originalOneReleaseForEditingQueryResponse.data.project.release.name; + expect(putCreateReleaseNotification).toHaveBeenCalledTimes(1); + expect(putCreateReleaseNotification).toHaveBeenCalledWith(projectPath, releaseName); + }); }); describe(`when the URL does not contain a "${BACK_URL_PARAM}" parameter`, () => { diff --git a/spec/frontend/releases/components/app_show_spec.js b/spec/frontend/releases/components/app_show_spec.js index c5cb8589ee8..efe72e8000a 100644 --- a/spec/frontend/releases/components/app_show_spec.js +++ b/spec/frontend/releases/components/app_show_spec.js @@ -5,12 +5,14 @@ import oneReleaseQueryResponse from 'test_fixtures/graphql/releases/graphql/quer import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; +import { popCreateReleaseNotification } from '~/releases/release_notification_service'; import ReleaseShowApp from '~/releases/components/app_show.vue'; import ReleaseBlock from '~/releases/components/release_block.vue'; import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue'; import oneReleaseQuery from '~/releases/graphql/queries/one_release.query.graphql'; jest.mock('~/flash'); +jest.mock('~/releases/release_notification_service'); Vue.use(VueApollo); @@ -88,6 +90,11 @@ describe('Release show component', () => { createComponent({ apolloProvider }); }); + it('shows info notification on mount', () => { + expect(popCreateReleaseNotification).toHaveBeenCalledTimes(1); + expect(popCreateReleaseNotification).toHaveBeenCalledWith(MOCK_FULL_PATH); + }); + it('builds a GraphQL with the expected variables', () => { expect(queryHandler).toHaveBeenCalledTimes(1); expect(queryHandler).toHaveBeenCalledWith({ diff --git a/spec/frontend/releases/release_notification_service_spec.js b/spec/frontend/releases/release_notification_service_spec.js new file mode 100644 index 00000000000..2344d4b929a --- /dev/null +++ b/spec/frontend/releases/release_notification_service_spec.js @@ -0,0 +1,57 @@ +import { + popCreateReleaseNotification, + putCreateReleaseNotification, +} from '~/releases/release_notification_service'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; + +jest.mock('~/flash'); + +describe('~/releases/release_notification_service', () => { + const projectPath = 'test-project-path'; + const releaseName = 'test-release-name'; + + const storageKey = `createRelease:${projectPath}`; + + describe('prepareCreateReleaseFlash', () => { + it('should set the session storage with project path key and release name value', () => { + putCreateReleaseNotification(projectPath, releaseName); + + const item = window.sessionStorage.getItem(storageKey); + + expect(item).toBe(releaseName); + }); + }); + + describe('showNotificationsIfPresent', () => { + describe('if notification is prepared', () => { + beforeEach(() => { + window.sessionStorage.setItem(storageKey, releaseName); + popCreateReleaseNotification(projectPath); + }); + + it('should remove storage key', () => { + const item = window.sessionStorage.getItem(storageKey); + + expect(item).toBe(null); + }); + + it('should create a flash message', () => { + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ + message: `Release ${releaseName} has been successfully created.`, + variant: VARIANT_SUCCESS, + }); + }); + }); + + describe('if notification is not prepared', () => { + beforeEach(() => { + popCreateReleaseNotification(projectPath); + }); + + it('should not create a flash message', () => { + expect(createAlert).toHaveBeenCalledTimes(0); + }); + }); + }); +}); diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js index eeee6747349..ca3b2d5f734 100644 --- a/spec/frontend/releases/stores/modules/detail/actions_spec.js +++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js @@ -23,6 +23,8 @@ jest.mock('~/api/tags_api'); jest.mock('~/flash'); +jest.mock('~/releases/release_notification_service'); + jest.mock('~/lib/utils/url_utility', () => ({ redirectTo: jest.fn(), joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths, @@ -41,9 +43,12 @@ describe('Release edit/new actions', () => { let releaseResponse; let error; + const projectPath = 'test/project-path'; + const setupState = (updates = {}) => { state = { ...createState({ + projectPath, projectId: '18', isExistingRelease: true, tagName: releaseResponse.tag_name, diff --git a/spec/frontend/super_sidebar/components/merge_request_menu_spec.js b/spec/frontend/super_sidebar/components/merge_request_menu_spec.js new file mode 100644 index 00000000000..fe87c4be9c3 --- /dev/null +++ b/spec/frontend/super_sidebar/components/merge_request_menu_spec.js @@ -0,0 +1,46 @@ +import { GlBadge, GlDisclosureDropdown } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import MergeRequestMenu from '~/super_sidebar/components/merge_request_menu.vue'; +import { mergeRequestMenuGroup } from '../mock_data'; + +describe('MergeRequestMenu component', () => { + let wrapper; + + const findGlBadge = (at) => wrapper.findAllComponents(GlBadge).at(at); + const findGlDisclosureDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findLink = () => wrapper.findByRole('link'); + + const createWrapper = () => { + wrapper = mountExtended(MergeRequestMenu, { + propsData: { + items: mergeRequestMenuGroup, + }, + }); + }; + + describe('default', () => { + beforeEach(() => { + createWrapper(); + }); + + it('passes the items to the disclosure dropdown', () => { + expect(findGlDisclosureDropdown().props('items')).toBe(mergeRequestMenuGroup); + }); + + it('renders item text and count in link', () => { + const { text, href, count } = mergeRequestMenuGroup[0].items[0]; + expect(findLink().text()).toContain(text); + expect(findLink().text()).toContain(String(count)); + expect(findLink().attributes('href')).toBe(href); + }); + + it('renders item count string in badge', () => { + const { count } = mergeRequestMenuGroup[0].items[0]; + expect(findGlBadge(0).text()).toBe(String(count)); + }); + + it('renders 0 string when count is empty', () => { + expect(findGlBadge(1).text()).toBe(String(0)); + }); + }); +}); diff --git a/spec/frontend/super_sidebar/components/user_bar_spec.js b/spec/frontend/super_sidebar/components/user_bar_spec.js index d7e658a1451..eceb792c3db 100644 --- a/spec/frontend/super_sidebar/components/user_bar_spec.js +++ b/spec/frontend/super_sidebar/components/user_bar_spec.js @@ -1,6 +1,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { __ } from '~/locale'; import CreateMenu from '~/super_sidebar/components/create_menu.vue'; +import MergeRequestMenu from '~/super_sidebar/components/merge_request_menu.vue'; import Counter from '~/super_sidebar/components/counter.vue'; import UserBar from '~/super_sidebar/components/user_bar.vue'; import { sidebarData } from '../mock_data'; @@ -10,6 +11,7 @@ describe('UserBar component', () => { const findCreateMenu = () => wrapper.findComponent(CreateMenu); const findCounter = (at) => wrapper.findAllComponents(Counter).at(at); + const findMergeRequestMenu = () => wrapper.findComponent(MergeRequestMenu); const createWrapper = (props = {}) => { wrapper = shallowMountExtended(UserBar, { @@ -33,12 +35,21 @@ describe('UserBar component', () => { expect(findCreateMenu().props('groups')).toBe(sidebarData.create_new_menu_groups); }); + it('passes the "Merge request" menu groups to the merge_request_menu component', () => { + expect(findMergeRequestMenu().props('items')).toBe(sidebarData.merge_request_menu); + }); + it('renders issues counter', () => { expect(findCounter(0).props('count')).toBe(sidebarData.assigned_open_issues_count); expect(findCounter(0).props('href')).toBe(sidebarData.issues_dashboard_path); expect(findCounter(0).props('label')).toBe(__('Issues')); }); + it('renders merge requests counter', () => { + expect(findCounter(1).props('count')).toBe(sidebarData.total_merge_requests_count); + expect(findCounter(1).props('label')).toBe(__('Merge requests')); + }); + it('renders todos counter', () => { expect(findCounter(2).props('count')).toBe(sidebarData.todos_pending_count); expect(findCounter(2).props('href')).toBe('/dashboard/todos'); diff --git a/spec/frontend/super_sidebar/mock_data.js b/spec/frontend/super_sidebar/mock_data.js index 379e4c2bffb..0194360cb57 100644 --- a/spec/frontend/super_sidebar/mock_data.js +++ b/spec/frontend/super_sidebar/mock_data.js @@ -39,15 +39,34 @@ export const createNewMenuGroups = [ }, ]; +export const mergeRequestMenuGroup = [ + { + name: 'Merge requests', + items: [ + { + text: 'Assigned', + href: '/dashboard/merge_requests?assignee_username=root', + count: 4, + }, + { + text: 'Review requests', + href: '/dashboard/merge_requests?reviewer_username=root', + count: 0, + }, + ], + }, +]; + export const sidebarData = { name: 'Administrator', username: 'root', avatar_url: 'path/to/img_administrator', assigned_open_issues_count: 1, - assigned_open_merge_requests_count: 2, todos_pending_count: 3, issues_dashboard_path: 'path/to/issues', + total_merge_requests_count: 4, create_new_menu_groups: createNewMenuGroups, + merge_request_menu: mergeRequestMenuGroup, support_path: '/support', display_whats_new: true, }; diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb index 4151789372b..ea8018d4413 100644 --- a/spec/graphql/types/project_type_spec.rb +++ b/spec/graphql/types/project_type_spec.rb @@ -285,6 +285,17 @@ RSpec.describe GitlabSchema.types['Project'] do end end end + + context 'with empty repository' do + let_it_be(:project) { create(:project_empty_repo) } + + it 'raises an error' do + expect(subject['errors'][0]['message']).to eq('You must <a target="_blank" rel="noopener noreferrer" ' \ + 'href="http://localhost/help/user/project/repository/index.md#' \ + 'add-files-to-a-repository">add at least one file to the ' \ + 'repository</a> before using Security features.') + end + end end describe 'issue field' do diff --git a/spec/helpers/sidebars_helper_spec.rb b/spec/helpers/sidebars_helper_spec.rb index ecbc1597bdf..0622e73936d 100644 --- a/spec/helpers/sidebars_helper_spec.rb +++ b/spec/helpers/sidebars_helper_spec.rb @@ -54,8 +54,10 @@ RSpec.describe SidebarsHelper do before do allow(helper).to receive(:current_user) { user } Rails.cache.write(['users', user.id, 'assigned_open_issues_count'], 1) - Rails.cache.write(['users', user.id, 'assigned_open_merge_requests_count'], 2) + Rails.cache.write(['users', user.id, 'assigned_open_merge_requests_count'], 4) + Rails.cache.write(['users', user.id, 'review_requested_open_merge_requests_count'], 0) Rails.cache.write(['users', user.id, 'todos_pending_count'], 3) + Rails.cache.write(['users', user.id, 'total_merge_requests_count'], 4) end it 'returns sidebar values from user', :use_clean_rails_memory_store_caching do @@ -64,14 +66,34 @@ RSpec.describe SidebarsHelper do username: user.username, avatar_url: user.avatar_url, assigned_open_issues_count: 1, - assigned_open_merge_requests_count: 2, todos_pending_count: 3, issues_dashboard_path: issues_dashboard_path(assignee_username: user.username), + total_merge_requests_count: 4, support_path: helper.support_url, display_whats_new: helper.display_whats_new? }) end + it 'returns "Merge requests" menu', :use_clean_rails_memory_store_caching do + expect(subject[:merge_request_menu]).to eq([ + { + name: _('Merge requests'), + items: [ + { + text: _('Assigned'), + href: merge_requests_dashboard_path(assignee_username: user.username), + count: 4 + }, + { + text: _('Review requests'), + href: merge_requests_dashboard_path(reviewer_username: user.username), + count: 0 + } + ] + } + ]) + end + it 'returns "Create new" menu groups without headers', :use_clean_rails_memory_store_caching do expect(subject[:create_new_menu_groups]).to eq([ { diff --git a/spec/initializers/memory_watchdog_spec.rb b/spec/initializers/memory_watchdog_spec.rb index 92834c889c2..ef24da0071b 100644 --- a/spec/initializers/memory_watchdog_spec.rb +++ b/spec/initializers/memory_watchdog_spec.rb @@ -2,7 +2,7 @@ require 'fast_spec_helper' -RSpec.describe 'memory watchdog' do +RSpec.describe 'memory watchdog', feature_category: :application_performance do shared_examples 'starts configured watchdog' do |configure_monitor_method| shared_examples 'configures and starts watchdog' do it "correctly configures and starts watchdog", :aggregate_failures do @@ -104,11 +104,7 @@ RSpec.describe 'memory watchdog' do allow(Gitlab::Runtime).to receive(:sidekiq?).and_return(true) end - it 'does not register life-cycle hook' do - expect(Gitlab::Cluster::LifecycleEvents).not_to receive(:on_worker_start) - - run_initializer - end + it_behaves_like 'starts configured watchdog', :configure_for_sidekiq end end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 9fe8b960c8b..fd5d33a9b6f 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -5537,4 +5537,33 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev end end end + + describe '#prepared?' do + subject(:merge_request) { build_stubbed(:merge_request, prepared_at: prepared_at) } + + context 'when prepared_at is nil' do + let(:prepared_at) { nil } + + it 'returns false' do + expect(merge_request.prepared?).to be_falsey + end + end + + context 'when prepared_at is not nil' do + let(:prepared_at) { Time.current } + + it 'returns true' do + expect(merge_request.prepared?).to be_truthy + end + end + end + + describe 'prepare' do + it 'calls NewMergeRequestWorker' do + expect(NewMergeRequestWorker).to receive(:perform_async) + .with(subject.id, subject.author_id) + + subject.prepare + end + end end diff --git a/spec/models/wiki_directory_spec.rb b/spec/models/wiki_directory_spec.rb index 44c6f6c9c1a..90ff42998ae 100644 --- a/spec/models/wiki_directory_spec.rb +++ b/spec/models/wiki_directory_spec.rb @@ -13,15 +13,20 @@ RSpec.describe WikiDirectory do let_it_be(:toplevel1) { build(:wiki_page, title: 'aaa-toplevel1') } let_it_be(:toplevel2) { build(:wiki_page, title: 'zzz-toplevel2') } let_it_be(:toplevel3) { build(:wiki_page, title: 'zzz-toplevel3') } + let_it_be(:parent1) { build(:wiki_page, title: 'parent1') } + let_it_be(:parent2) { build(:wiki_page, title: 'parent2') } let_it_be(:child1) { build(:wiki_page, title: 'parent1/child1') } let_it_be(:child2) { build(:wiki_page, title: 'parent1/child2') } let_it_be(:child3) { build(:wiki_page, title: 'parent2/child3') } + let_it_be(:subparent) { build(:wiki_page, title: 'parent1/subparent') } let_it_be(:grandchild1) { build(:wiki_page, title: 'parent1/subparent/grandchild1') } let_it_be(:grandchild2) { build(:wiki_page, title: 'parent1/subparent/grandchild2') } it 'returns a nested array of entries' do entries = described_class.group_pages( - [toplevel1, toplevel2, toplevel3, child1, child2, child3, grandchild1, grandchild2].sort_by(&:title) + [toplevel1, toplevel2, toplevel3, + parent1, parent2, child1, child2, child3, + subparent, grandchild1, grandchild2].sort_by(&:title) ) expect(entries).to match( diff --git a/spec/requests/api/graphql/issues_spec.rb b/spec/requests/api/graphql/issues_spec.rb index 3dc79ef4d81..e437e1bbcb0 100644 --- a/spec/requests/api/graphql/issues_spec.rb +++ b/spec/requests/api/graphql/issues_spec.rb @@ -175,15 +175,21 @@ RSpec.describe 'getting an issue list at root level', feature_category: :team_pl end context 'when fetching issues from multiple projects' do - it 'avoids N+1 queries' do + it 'avoids N+1 queries', :use_sql_query_cache do post_query # warm-up - control = ActiveRecord::QueryRecorder.new { post_query } + control = ActiveRecord::QueryRecorder.new(skip_cached: false) { post_query } + expect_graphql_errors_to_be_empty new_private_project = create(:project, :private).tap { |project| project.add_developer(current_user) } create(:issue, project: new_private_project) - expect { post_query }.not_to exceed_query_limit(control) + private_group = create(:group, :private).tap { |group| group.add_developer(current_user) } + private_project = create(:project, :private, group: private_group) + create(:issue, project: private_project) + + expect { post_query }.not_to exceed_all_query_limit(control) + expect_graphql_errors_to_be_empty end end diff --git a/spec/services/google_cloud/fetch_google_ip_list_service_spec.rb b/spec/services/google_cloud/fetch_google_ip_list_service_spec.rb index ef77958fa60..e5f06824b9f 100644 --- a/spec/services/google_cloud/fetch_google_ip_list_service_spec.rb +++ b/spec/services/google_cloud/fetch_google_ip_list_service_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe GoogleCloud::FetchGoogleIpListService, :use_clean_rails_memory_store_caching, -:clean_gitlab_redis_rate_limiting, feature_category: :continuous_integration do +:clean_gitlab_redis_rate_limiting, feature_category: :build_artifacts do include StubRequests let(:google_cloud_ips) { File.read(Rails.root.join('spec/fixtures/cdn/google_cloud.json')) } diff --git a/spec/services/merge_requests/after_create_service_spec.rb b/spec/services/merge_requests/after_create_service_spec.rb index f477b2166d9..f2823b1f0c7 100644 --- a/spec/services/merge_requests/after_create_service_spec.rb +++ b/spec/services/merge_requests/after_create_service_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe MergeRequests::AfterCreateService do +RSpec.describe MergeRequests::AfterCreateService, feature_category: :code_review_workflow do let_it_be(:merge_request) { create(:merge_request) } subject(:after_create_service) do @@ -126,6 +126,17 @@ RSpec.describe MergeRequests::AfterCreateService do end end + it 'updates the prepared_at' do + # Need to reset the `prepared_at` since it can be already set in preceding tests. + merge_request.update!(prepared_at: nil) + + freeze_time do + expect { execute_service }.to change { merge_request.prepared_at } + .from(nil) + .to(Time.current) + end + end + it 'increments the usage data counter of create event' do counter = Gitlab::UsageDataCounters::MergeRequestCounter diff --git a/spec/services/security/ci_configuration/sast_create_service_spec.rb b/spec/services/security/ci_configuration/sast_create_service_spec.rb index 1e6dc367146..39c32567f3c 100644 --- a/spec/services/security/ci_configuration/sast_create_service_spec.rb +++ b/spec/services/security/ci_configuration/sast_create_service_spec.rb @@ -24,7 +24,45 @@ RSpec.describe Security::CiConfiguration::SastCreateService, :snowplow, feature_ include_examples 'services security ci configuration create service' - context "when committing to the default branch", :aggregate_failures do + RSpec.shared_examples_for 'commits directly to the default branch' do + it 'commits directly to the default branch' do + expect(project).to receive(:default_branch).twice.and_return('master') + + expect(result.status).to eq(:success) + expect(result.payload[:success_path]).to match(/#{Gitlab::Routing.url_helpers.project_new_merge_request_url(project, {})}(.*)description(.*)source_branch/) + expect(result.payload[:branch]).to eq('master') + end + end + + context 'when the repository is empty' do + let_it_be(:project) { create(:project_empty_repo) } + + context 'when initialize_with_sast is false' do + before do + project.add_developer(user) + end + + let(:params) { { initialize_with_sast: false } } + + it 'raises an error' do + expect { result }.to raise_error(Gitlab::Graphql::Errors::MutationError) + end + end + + context 'when initialize_with_sast is true' do + let(:params) { { initialize_with_sast: true } } + + subject(:result) { described_class.new(project, user, params, commit_on_default: true).execute } + + before do + project.add_maintainer(user) + end + + it_behaves_like 'commits directly to the default branch' + end + end + + context 'when committing to the default branch', :aggregate_failures do subject(:result) { described_class.new(project, user, params, commit_on_default: true).execute } let(:params) { {} } @@ -33,17 +71,13 @@ RSpec.describe Security::CiConfiguration::SastCreateService, :snowplow, feature_ project.add_developer(user) end - it "doesn't try to remove that branch on raised exceptions" do + it 'does not try to remove that branch on raised exceptions' do expect(Files::MultiService).to receive(:new).and_raise(StandardError, '_exception_') expect(project.repository).not_to receive(:rm_branch) expect { result }.to raise_error(StandardError, '_exception_') end - it "commits directly to the default branch" do - expect(result.status).to eq(:success) - expect(result.payload[:success_path]).to match(/#{Gitlab::Routing.url_helpers.project_new_merge_request_url(project, {})}(.*)description(.*)source_branch/) - expect(result.payload[:branch]).to eq('master') - end + it_behaves_like 'commits directly to the default branch' end end diff --git a/spec/support/rspec_order_todo.yml b/spec/support/rspec_order_todo.yml index 54ac7f5548e..58411fc3c4f 100644 --- a/spec/support/rspec_order_todo.yml +++ b/spec/support/rspec_order_todo.yml @@ -1006,7 +1006,6 @@ - './ee/spec/helpers/nav/new_dropdown_helper_spec.rb' - './ee/spec/helpers/nav/top_nav_helper_spec.rb' - './ee/spec/helpers/notes_helper_spec.rb' -- './ee/spec/helpers/paid_feature_callout_helper_spec.rb' - './ee/spec/helpers/path_locks_helper_spec.rb' - './ee/spec/helpers/preferences_helper_spec.rb' - './ee/spec/helpers/prevent_forking_helper_spec.rb' diff --git a/spec/support/shared_examples/services/security/ci_configuration/create_service_shared_examples.rb b/spec/support/shared_examples/services/security/ci_configuration/create_service_shared_examples.rb index 716be8c6210..209be09c807 100644 --- a/spec/support/shared_examples/services/security/ci_configuration/create_service_shared_examples.rb +++ b/spec/support/shared_examples/services/security/ci_configuration/create_service_shared_examples.rb @@ -160,6 +160,21 @@ RSpec.shared_examples_for 'services security ci configuration create service' do end end end + + context 'when the project is empty' do + let(:params) { nil } + let_it_be(:project) { create(:project_empty_repo) } + + it 'returns an error' do + expect { result }.to raise_error { |error| + expect(error).to be_a(Gitlab::Graphql::Errors::MutationError) + expect(error.message).to eq('You must <a target="_blank" rel="noopener noreferrer" ' \ + 'href="http://localhost/help/user/project/repository/index.md' \ + '#add-files-to-a-repository">add at least one file to the repository' \ + '</a> before using Security features.') + } + end + end end end end diff --git a/spec/uploaders/object_storage/cdn/google_cdn_spec.rb b/spec/uploaders/object_storage/cdn/google_cdn_spec.rb index 96755b7292b..184c664f6dc 100644 --- a/spec/uploaders/object_storage/cdn/google_cdn_spec.rb +++ b/spec/uploaders/object_storage/cdn/google_cdn_spec.rb @@ -3,7 +3,10 @@ require 'spec_helper' RSpec.describe ObjectStorage::CDN::GoogleCDN, - :use_clean_rails_memory_store_caching, :use_clean_rails_redis_caching, :sidekiq_inline do + :use_clean_rails_memory_store_caching, + :use_clean_rails_redis_caching, + :sidekiq_inline, + feature_category: :build_artifacts do # the google cdn is currently only used by build artifacts include StubRequests let(:key) { SecureRandom.hex } diff --git a/spec/uploaders/object_storage/cdn_spec.rb b/spec/uploaders/object_storage/cdn_spec.rb index 2a447921a19..a64e7000855 100644 --- a/spec/uploaders/object_storage/cdn_spec.rb +++ b/spec/uploaders/object_storage/cdn_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe ObjectStorage::CDN do +RSpec.describe ObjectStorage::CDN, feature_category: :build_artifacts do let(:cdn_options) do { 'object_store' => { diff --git a/spec/workers/google_cloud/fetch_google_ip_list_worker_spec.rb b/spec/workers/google_cloud/fetch_google_ip_list_worker_spec.rb index c0b32515d15..bdafc076465 100644 --- a/spec/workers/google_cloud/fetch_google_ip_list_worker_spec.rb +++ b/spec/workers/google_cloud/fetch_google_ip_list_worker_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe GoogleCloud::FetchGoogleIpListWorker do +RSpec.describe GoogleCloud::FetchGoogleIpListWorker, feature_category: :build_artifacts do describe '#perform' do it 'returns success' do allow_next_instance_of(GoogleCloud::FetchGoogleIpListService) do |service| diff --git a/spec/workers/new_merge_request_worker_spec.rb b/spec/workers/new_merge_request_worker_spec.rb index 358939a963a..a8e1c3f4bf1 100644 --- a/spec/workers/new_merge_request_worker_spec.rb +++ b/spec/workers/new_merge_request_worker_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe NewMergeRequestWorker do +RSpec.describe NewMergeRequestWorker, feature_category: :code_review_workflow do describe '#perform' do let(:worker) { described_class.new } @@ -71,19 +71,64 @@ RSpec.describe NewMergeRequestWorker do it_behaves_like 'a new merge request where the author cannot trigger notifications' end - context 'when everything is ok' do + include_examples 'an idempotent worker' do let(:user) { create(:user) } - - it 'creates a new event record' do - expect { worker.perform(merge_request.id, user.id) }.to change { Event.count }.from(0).to(1) - end - - it 'creates a notification for the mentioned user' do - expect(Notify).to receive(:new_merge_request_email) - .with(mentioned.id, merge_request.id, NotificationReason::MENTIONED) - .and_return(double(deliver_later: true)) - - worker.perform(merge_request.id, user.id) + let(:job_args) { [merge_request.id, user.id] } + + context 'when everything is ok' do + it 'creates a new event record' do + expect { worker.perform(merge_request.id, user.id) }.to change { Event.count }.from(0).to(1) + end + + it 'creates a notification for the mentioned user' do + expect(Notify).to receive(:new_merge_request_email) + .with(mentioned.id, merge_request.id, NotificationReason::MENTIONED) + .and_return(double(deliver_later: true)) + + worker.perform(merge_request.id, user.id) + end + + context 'when add_prepared_state_to_mr feature flag is off' do + before do + stub_feature_flags(add_prepared_state_to_mr: false) + end + + it 'calls the create service' do + expect_next_instance_of(MergeRequests::AfterCreateService, project: merge_request.target_project, current_user: user) do |service| + expect(service).to receive(:execute).with(merge_request) + end + + worker.perform(merge_request.id, user.id) + end + end + + context 'when add_prepared_state_to_mr feature flag is on' do + before do + stub_feature_flags(add_prepared_state_to_mr: true) + end + + context 'when the merge request is prepared' do + before do + merge_request.update!(prepared_at: Time.current) + end + + it 'does not call the create service' do + expect(MergeRequests::AfterCreateService).not_to receive(:new) + + worker.perform(merge_request.id, user.id) + end + end + + context 'when the merge request is not prepared' do + it 'calls the create service' do + expect_next_instance_of(MergeRequests::AfterCreateService, project: merge_request.target_project, current_user: user) do |service| + expect(service).to receive(:execute).with(merge_request) + end + + worker.perform(merge_request.id, user.id) + end + end + end end end end |