diff options
76 files changed, 2154 insertions, 552 deletions
diff --git a/.rubocop.yml b/.rubocop.yml index baaeab4ec6f..47dc85a79f0 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -543,3 +543,8 @@ Migration/CreateTableWithForeignKeys: # Disable this cop for all the existing migrations Exclude: - !ruby/regexp /\Adb\/(?:post_)?migrate\/(?:201[0-9]\d+|20200[0-8][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9])_.+\.rb\z/ + +Gitlab/RailsLogger: + Exclude: + - 'spec/**/*.rb' + - 'ee/spec/**/*.rb' diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue index 2c6302e4f77..84d54466a10 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue @@ -1,4 +1,5 @@ <script> +import produce from 'immer'; import { s__ } from '~/locale'; import Todo from '~/sidebar/components/todo_toggle/todo.vue'; import createAlertTodoMutation from '../../graphql/mutations/alert_todo_create.mutation.graphql'; @@ -109,12 +110,15 @@ export default { }); }, updateCache(store) { - const data = store.readQuery({ + const sourceData = store.readQuery({ query: alertQuery, variables: this.getAlertQueryVariables, }); - data.project.alertManagementAlerts.nodes[0].todos.nodes.shift(); + const data = produce(sourceData, draftData => { + // eslint-disable-next-line no-param-reassign + draftData.project.alertManagementAlerts.nodes[0].todos.nodes = []; + }); store.writeQuery({ query: alertQuery, diff --git a/app/assets/javascripts/alert_management/details.js b/app/assets/javascripts/alert_management/details.js index dccf990f0b4..c2020dfcbe3 100644 --- a/app/assets/javascripts/alert_management/details.js +++ b/app/assets/javascripts/alert_management/details.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import produce from 'immer'; import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; import createDefaultClient from '~/lib/graphql'; import createRouter from './router'; @@ -16,8 +17,11 @@ export default selector => { const resolvers = { Mutation: { toggleSidebarStatus: (_, __, { cache }) => { - const data = cache.readQuery({ query: sidebarStatusQuery }); - data.sidebarStatus = !data.sidebarStatus; + const sourceData = cache.readQuery({ query: sidebarStatusQuery }); + const data = produce(sourceData, draftData => { + // eslint-disable-next-line no-param-reassign + draftData.sidebarStatus = !draftData.sidebarStatus; + }); cache.writeQuery({ query: sidebarStatusQuery, data }); }, }, @@ -34,6 +38,7 @@ export default selector => { return defaultDataIdFromObject(object); }, }, + assumeImmutableResults: true, }), }); diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index 3be592baf29..670c42cbdac 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -23,6 +23,8 @@ import { s__ } from '~/locale'; import { mergeUrlParams, joinPaths, visitUrl } from '~/lib/utils/url_utility'; import getIncidents from '../graphql/queries/get_incidents.query.graphql'; import getIncidentsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql'; +import SeverityToken from '~/sidebar/components/severity/severity.vue'; +import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants'; import { I18N, DEFAULT_PAGE_SIZE, INCIDENT_SEARCH_DELAY, INCIDENT_STATUS_TABS } from '../constants'; const TH_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' }; @@ -45,6 +47,12 @@ export default { statusTabs: INCIDENT_STATUS_TABS, fields: [ { + key: 'severity', + label: s__('IncidentManagement|Severity'), + thClass: `gl-pointer-events-none`, + tdClass, + }, + { key: 'title', label: s__('IncidentManagement|Incident'), thClass: `gl-pointer-events-none gl-w-half`, @@ -82,6 +90,7 @@ export default { PublishedCell: () => import('ee_component/incidents/components/published_cell.vue'), GlBadge, GlEmptyState, + SeverityToken, }, directives: { GlTooltip: GlTooltipDirective, @@ -280,6 +289,9 @@ export default { this.sort = `${sortingColumn}_${sortingDirection}`; }, + getSeverity(severity) { + return INCIDENT_SEVERITY[severity]; + }, }, }; </script> @@ -348,6 +360,10 @@ export default { @row-clicked="navigateToIncidentDetails" @sort-changed="fetchSortedData" > + <template #cell(severity)="{ item }"> + <severity-token :severity="getSeverity(item.severity)" /> + </template> + <template #cell(title)="{ item }"> <div :class="{ 'gl-display-flex gl-align-items-center': item.state === 'closed' }"> <div class="gl-max-w-full text-truncate" :title="item.title">{{ item.title }}</div> diff --git a/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql b/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql index 0f56e8640bd..d7e51b076d8 100644 --- a/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql +++ b/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql @@ -40,6 +40,7 @@ query getIncidents( } } statusPagePublishedIncident + severity } pageInfo { hasNextPage diff --git a/app/assets/stylesheets/page_bundles/todos.scss b/app/assets/stylesheets/page_bundles/todos.scss index 2114d226de3..3eec5b53a30 100644 --- a/app/assets/stylesheets/page_bundles/todos.scss +++ b/app/assets/stylesheets/page_bundles/todos.scss @@ -12,8 +12,8 @@ flex-direction: row; &:hover { - background-color: $blue-50; - border-color: $blue-200; + background-color: var(--blue-50, $blue-50); + border-color: var(--blue-200, $blue-200); cursor: pointer; } @@ -22,7 +22,7 @@ border-bottom: 1px solid transparent; &:hover { - border-color: $blue-200; + border-color: var(--blue-200, $blue-200); } } @@ -46,11 +46,9 @@ } &.todo-pending.done-reversible { - background-color: $white; - &:hover { - border-color: $white-normal; - background-color: $gray-light; + border-color: var(--border-color, $border-color); + background-color: var(--gray-50, $gray-50); border-top: 1px solid transparent; .todo-avatar, @@ -65,7 +63,7 @@ } .btn { - background-color: $gray-light; + background-color: var(--gray-50, $gray-50); } } } @@ -105,15 +103,15 @@ .todo-label, .todo-project { a { - color: $blue-600; font-weight: $gl-font-weight-normal; + color: var(--blue-600, $blue-600); } } .todo-body { .badge.badge-pill, p { - color: $gl-text-color; + color: var(--gl-text-color, $gl-text-color); } .md { @@ -127,9 +125,9 @@ pre { border: 0; - background: $gray-light; + background: var(--gray-50, $gray-50); border-radius: 0; - color: $gray-500; + color: var(--gray-500, $gray-500); margin: 0 20px; overflow: hidden; } @@ -185,7 +183,7 @@ .todo-body { margin: 0; - border-left: 2px solid $gray-100; + border-left: 2px solid var(--border-color, $border-color); padding-left: 10px; } } diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss index 6ff2cba325c..bfbcb8c13c6 100644 --- a/app/assets/stylesheets/themes/_dark.scss +++ b/app/assets/stylesheets/themes/_dark.scss @@ -85,6 +85,86 @@ $white-light: #2b2b2b; $white-normal: #333; $white-dark: #444; +$border-color: #4f4f4f; + +body.gl-dark { + --gray-10: #{$gray-10}; + --gray-50: #{$gray-50}; + --gray-100: #{$gray-100}; + --gray-200: #{$gray-200}; + --gray-300: #{$gray-300}; + --gray-400: #{$gray-400}; + --gray-500: #{$gray-500}; + --gray-600: #{$gray-600}; + --gray-700: #{$gray-700}; + --gray-800: #{$gray-800}; + --gray-900: #{$gray-900}; + --gray-950: #{$gray-950}; + + --green-50: #{$green-50}; + --green-100: #{$green-100}; + --green-200: #{$green-200}; + --green-300: #{$green-300}; + --green-400: #{$green-400}; + --green-500: #{$green-500}; + --green-600: #{$green-600}; + --green-700: #{$green-700}; + --green-800: #{$green-800}; + --green-900: #{$green-900}; + --green-950: #{$green-950}; + + --blue-50: #{$blue-50}; + --blue-100: #{$blue-100}; + --blue-200: #{$blue-200}; + --blue-300: #{$blue-300}; + --blue-400: #{$blue-400}; + --blue-500: #{$blue-500}; + --blue-600: #{$blue-600}; + --blue-700: #{$blue-700}; + --blue-800: #{$blue-800}; + --blue-900: #{$blue-900}; + --blue-950: #{$blue-950}; + + --orange-50: #{$orange-50}; + --orange-100: #{$orange-100}; + --orange-200: #{$orange-200}; + --orange-300: #{$orange-300}; + --orange-400: #{$orange-400}; + --orange-500: #{$orange-500}; + --orange-600: #{$orange-600}; + --orange-700: #{$orange-700}; + --orange-800: #{$orange-800}; + --orange-900: #{$orange-900}; + --orange-950: #{$orange-950}; + + --red-50: #{$red-50}; + --red-100: #{$red-100}; + --red-200: #{$red-200}; + --red-300: #{$red-300}; + --red-400: #{$red-400}; + --red-500: #{$red-500}; + --red-600: #{$red-600}; + --red-700: #{$red-700}; + --red-800: #{$red-800}; + --red-900: #{$red-900}; + --red-950: #{$red-950}; + + --indigo-50: #{$indigo-50}; + --indigo-100: #{$indigo-100}; + --indigo-200: #{$indigo-200}; + --indigo-300: #{$indigo-300}; + --indigo-400: #{$indigo-400}; + --indigo-500: #{$indigo-500}; + --indigo-600: #{$indigo-600}; + --indigo-700: #{$indigo-700}; + --indigo-800: #{$indigo-800}; + --indigo-900: #{$indigo-900}; + --indigo-950: #{$indigo-950}; + + --gl-text-color: #{$gray-900}; + --border-color: #{$border-color}; +} + $border-white-light: $gray-900; $border-white-normal: $gray-900; diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index db3c90007bc..d3260e3b609 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -15,6 +15,7 @@ class SearchController < ApplicationController around_action :allow_gitaly_ref_name_caching + before_action :block_anonymous_global_searches skip_before_action :authenticate_user! requires_cross_project_access if: -> do search_term_present = params[:search].present? || params[:term].present? @@ -128,6 +129,16 @@ class SearchController < ApplicationController payload[:metadata]['meta.search.search'] = params[:search] payload[:metadata]['meta.search.scope'] = params[:scope] end + + def block_anonymous_global_searches + return if params[:project_id].present? || params[:group_id].present? + return if current_user + return unless ::Feature.enabled?(:block_anonymous_global_searches) + + store_location_for(:user, request.fullpath) + + redirect_to new_user_session_path, alert: _('You must be logged in to search across all of GitLab') + end end SearchController.prepend_if_ee('EE::SearchController') diff --git a/app/finders/ci/jobs_finder.rb b/app/finders/ci/jobs_finder.rb index 2169bf8c53e..8515b77ec0b 100644 --- a/app/finders/ci/jobs_finder.rb +++ b/app/finders/ci/jobs_finder.rb @@ -4,31 +4,38 @@ module Ci class JobsFinder include Gitlab::Allowable - def initialize(current_user:, project: nil, params: {}) + def initialize(current_user:, pipeline: nil, project: nil, params: {}, type: ::Ci::Build) + @pipeline = pipeline @current_user = current_user @project = project @params = params + @type = type + raise ArgumentError 'type must be a subclass of Ci::Processable' unless type < ::Ci::Processable end def execute builds = init_collection.order_id_desc filter_by_scope(builds) rescue Gitlab::Access::AccessDeniedError - Ci::Build.none + type.none end private - attr_reader :current_user, :project, :params + attr_reader :current_user, :pipeline, :project, :params, :type def init_collection - project ? project_builds : all_builds + if Feature.enabled?(:ci_jobs_finder_refactor) + pipeline_jobs || project_jobs || all_jobs + else + project ? project_builds : all_jobs + end end - def all_builds + def all_jobs raise Gitlab::Access::AccessDeniedError unless current_user&.admin? - Ci::Build.all + type.all end def project_builds @@ -37,7 +44,25 @@ module Ci project.builds.relevant end + def project_jobs + return unless project + raise Gitlab::Access::AccessDeniedError unless can?(current_user, :read_build, project) + + jobs_by_type(project, type).relevant + end + + def pipeline_jobs + return unless pipeline + raise Gitlab::Access::AccessDeniedError unless can?(current_user, :read_build, pipeline) + + jobs_by_type(pipeline, type).latest + end + def filter_by_scope(builds) + if Feature.enabled?(:ci_jobs_finder_refactor) + return filter_by_statuses!(params[:scope], builds) if params[:scope].is_a?(Array) + end + case params[:scope] when 'pending' builds.pending.reverse_order @@ -49,5 +74,23 @@ module Ci builds end end + + def filter_by_statuses!(statuses, builds) + unknown_statuses = params[:scope] - ::CommitStatus::AVAILABLE_STATUSES + raise ArgumentError, 'Scope contains invalid value(s)' unless unknown_statuses.empty? + + builds.where(status: params[:scope]) # rubocop: disable CodeReuse/ActiveRecord + end + + def jobs_by_type(relation, type) + case type.name + when ::Ci::Build.name + relation.builds + when ::Ci::Bridge.name + relation.bridges + else + raise ArgumentError, "finder does not support #{type} type" + end + end end end diff --git a/app/graphql/mutations/boards/destroy.rb b/app/graphql/mutations/boards/destroy.rb new file mode 100644 index 00000000000..7c381113d38 --- /dev/null +++ b/app/graphql/mutations/boards/destroy.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Mutations + module Boards + class Destroy < ::Mutations::BaseMutation + graphql_name 'DestroyBoard' + + field :board, + Types::BoardType, + null: true, + description: 'The board after mutation' + argument :id, + ::Types::GlobalIDType[::Board], + required: true, + description: 'The global ID of the board to destroy' + + authorize :admin_board + + def resolve(id:) + board = authorized_find!(id: id) + + response = ::Boards::DestroyService.new(board.resource_parent, current_user).execute(board) + + { + board: response.success? ? nil : board, + errors: response.errors + } + end + + private + + def find_object(id:) + GitlabSchema.object_from_id(id, expected_type: ::Board) + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index d970bef8959..b2732d83aac 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -14,6 +14,7 @@ module Types mount_mutation Mutations::AwardEmojis::Add mount_mutation Mutations::AwardEmojis::Remove mount_mutation Mutations::AwardEmojis::Toggle + mount_mutation Mutations::Boards::Destroy mount_mutation Mutations::Boards::Issues::IssueMoveList mount_mutation Mutations::Boards::Lists::Create mount_mutation Mutations::Boards::Lists::Update diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 58c26e8c806..4b61da461cf 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -48,6 +48,14 @@ module Ci raise NotImplementedError end + def self.with_preloads + preload( + :metadata, + downstream_pipeline: [project: [:route, { namespace: :route }]], + project: [:namespace] + ) + end + def schedule_downstream_pipeline! raise InvalidBridgeTypeError unless downstream_project diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index eccd1ea5490..e9784a6305e 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -175,7 +175,6 @@ module Ci end scope :queued_before, ->(time) { where(arel_table[:queued_at].lt(time)) } - scope :order_id_desc, -> { order('ci_builds.id DESC') } scope :preload_project_and_pipeline_project, -> do preload(Ci::Pipeline::PROJECT_ROUTE_AND_NAMESPACE_ROUTE, @@ -213,6 +212,10 @@ module Ci .execute(build) # rubocop: enable CodeReuse/ServiceClass end + + def with_preloads + preload(:job_artifacts_archive, :job_artifacts, project: [:namespace]) + end end state_machine :status do diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb index c21759a3c3b..874415e7bf4 100644 --- a/app/models/clusters/agent.rb +++ b/app/models/clusters/agent.rb @@ -8,6 +8,8 @@ module Clusters has_many :agent_tokens, class_name: 'Clusters::AgentToken' + scope :with_name, -> (name) { where(name: name) } + validates :name, presence: true, length: { maximum: 63 }, diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 4539336b717..2f0596c93cc 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -32,6 +32,8 @@ class CommitStatus < ApplicationRecord where(allow_failure: true, status: [:failed, :canceled]) end + scope :order_id_desc, -> { order('ci_builds.id DESC') } + scope :exclude_ignored, -> do # We want to ignore failed but allowed to fail jobs. # diff --git a/app/services/boards/destroy_service.rb b/app/services/boards/destroy_service.rb index ea0c1394aa3..8f3d4b58b7b 100644 --- a/app/services/boards/destroy_service.rb +++ b/app/services/boards/destroy_service.rb @@ -3,9 +3,13 @@ module Boards class DestroyService < Boards::BaseService def execute(board) - return false if parent.boards.size == 1 + if parent.boards.size == 1 + return ServiceResponse.error(message: "The board could not be deleted, because the parent doesn't have any other boards.") + end - board.destroy + board.destroy! + + ServiceResponse.success end end end diff --git a/app/services/concerns/merge_requests/removes_refs.rb b/app/services/concerns/merge_requests/removes_refs.rb new file mode 100644 index 00000000000..87c15746548 --- /dev/null +++ b/app/services/concerns/merge_requests/removes_refs.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module MergeRequests + module RemovesRefs + def cleanup_refs(merge_request) + CleanupRefsService.schedule(merge_request) + end + end +end diff --git a/app/services/merge_requests/cleanup_refs_service.rb b/app/services/merge_requests/cleanup_refs_service.rb new file mode 100644 index 00000000000..e34d568cdcd --- /dev/null +++ b/app/services/merge_requests/cleanup_refs_service.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module MergeRequests + class CleanupRefsService + include BaseServiceUtility + + TIME_THRESHOLD = 14.days + + attr_reader :merge_request + + def self.schedule(merge_request) + MergeRequestCleanupRefsWorker.perform_in(TIME_THRESHOLD, merge_request.id) + end + + def initialize(merge_request) + @merge_request = merge_request + @repository = merge_request.project.repository + @ref_path = merge_request.ref_path + @ref_head_sha = @repository.commit(merge_request.ref_path).id + end + + def execute + return error("Merge request has not been closed nor merged for #{TIME_THRESHOLD.inspect}.") unless eligible? + + # Ensure that commit shas of refs are kept around so we won't lose them when GC runs. + keep_around + + return error('Failed to create keep around refs.') unless kept_around? + + delete_refs + success + end + + private + + attr_reader :repository, :ref_path, :ref_head_sha + + def eligible? + return met_time_threshold?(merge_request.metrics&.latest_closed_at) if merge_request.closed? + + merge_request.merged? && met_time_threshold?(merge_request.metrics&.merged_at) + end + + def met_time_threshold?(attr) + attr.nil? || attr.to_i <= TIME_THRESHOLD.ago.to_i + end + + def kept_around? + Gitlab::Git::KeepAround.new(repository).kept_around?(ref_head_sha) + end + + def keep_around + repository.keep_around(ref_head_sha) + end + + def delete_refs + repository.delete_refs(ref_path) + end + end +end diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb index c2174d2a130..b0a7face594 100644 --- a/app/services/merge_requests/close_service.rb +++ b/app/services/merge_requests/close_service.rb @@ -2,6 +2,8 @@ module MergeRequests class CloseService < MergeRequests::BaseService + include RemovesRefs + def execute(merge_request, commit = nil) return merge_request unless can?(current_user, :update_merge_request, merge_request) @@ -19,6 +21,7 @@ module MergeRequests merge_request.update_project_counter_caches cleanup_environments(merge_request) abort_auto_merge(merge_request, 'merge request was closed') + cleanup_refs(merge_request) end merge_request diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb index fdf8f442297..1c78fca3c26 100644 --- a/app/services/merge_requests/post_merge_service.rb +++ b/app/services/merge_requests/post_merge_service.rb @@ -7,6 +7,8 @@ module MergeRequests # and execute all hooks and notifications # class PostMergeService < MergeRequests::BaseService + include RemovesRefs + def execute(merge_request) merge_request.mark_as_merged close_issues(merge_request) @@ -20,6 +22,7 @@ module MergeRequests delete_non_latest_diffs(merge_request) cancel_review_app_jobs!(merge_request) cleanup_environments(merge_request) + cleanup_refs(merge_request) end private diff --git a/app/views/projects/merge_requests/_how_to_merge.html.haml b/app/views/projects/merge_requests/_how_to_merge.html.haml index df81e608c3e..a831972a823 100644 --- a/app/views/projects/merge_requests/_how_to_merge.html.haml +++ b/app/views/projects/merge_requests/_how_to_merge.html.haml @@ -53,4 +53,4 @@ %strong Tip: = succeed '.' do You can also checkout merge requests locally by - = link_to 'following these guidelines', help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: "checkout-merge-requests-locally"), target: '_blank', rel: 'noopener noreferrer' + = link_to 'following these guidelines', help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: "checkout-merge-requests-locally-through-the-head-ref"), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/shared/issuable/form/_type_selector.html.haml b/app/views/shared/issuable/form/_type_selector.html.haml index 67f0826def1..7ba4470f678 100644 --- a/app/views/shared/issuable/form/_type_selector.html.haml +++ b/app/views/shared/issuable/form/_type_selector.html.haml @@ -1,4 +1,4 @@ -- return unless issuable.supports_issue_type? +- return unless issuable.supports_issue_type? && can?(current_user, :admin_issue, @project) .form-group.row.gl-mb-0 = form.label :type, 'Type', class: 'col-form-label col-sm-2' diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index 4ef8a9dd842..27b771b281b 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -8,7 +8,7 @@ .gl-mb-2 %strong{ data: { qa_selector: "milestone_link", qa_milestone_title: milestone.title } } = link_to truncate(milestone.title, length: 100), milestone_path(milestone) - - if @group + - if @group || dashboard = " - #{milestone_type}" - if milestone.due_date || milestone.start_date @@ -62,7 +62,3 @@ = link_to s_('Milestones|Reopen Milestone'), milestone_path(milestone, milestone: { state_event: :activate }), method: :put, class: "btn btn-sm btn-grouped btn-reopen" - else = link_to s_('Milestones|Close Milestone'), milestone_path(milestone, milestone: { state_event: :close }), method: :put, class: "btn btn-sm btn-grouped btn-close" - - - if dashboard - .label-badge.label-badge-gray - = milestone_type diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index a071c6673a2..5469010608c 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -1492,6 +1492,14 @@ :weight: 5 :idempotent: :tags: [] +- :name: merge_request_cleanup_refs + :feature_category: :source_code_management + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: merge_request_mergeability_check :feature_category: :source_code_management :has_external_dependencies: diff --git a/app/workers/merge_request_cleanup_refs_worker.rb b/app/workers/merge_request_cleanup_refs_worker.rb new file mode 100644 index 00000000000..37774658ba8 --- /dev/null +++ b/app/workers/merge_request_cleanup_refs_worker.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class MergeRequestCleanupRefsWorker + include ApplicationWorker + + feature_category :source_code_management + idempotent! + + def perform(merge_request_id) + merge_request = MergeRequest.find_by_id(merge_request_id) + + unless merge_request + logger.error("Failed to find merge request with ID: #{merge_request_id}") + return + end + + result = ::MergeRequests::CleanupRefsService.new(merge_request).execute + + return if result[:status] == :success + + logger.error("Failed cleanup refs of merge request (#{merge_request_id}): #{result[:message]}") + end +end diff --git a/changelogs/unreleased/223156-remove-stale-mr-refs.yml b/changelogs/unreleased/223156-remove-stale-mr-refs.yml new file mode 100644 index 00000000000..d22fb45ad66 --- /dev/null +++ b/changelogs/unreleased/223156-remove-stale-mr-refs.yml @@ -0,0 +1,5 @@ +--- +title: Clean up stale merge request HEAD ref +merge_request: 41555 +author: +type: performance diff --git a/changelogs/unreleased/225393-Close-Milestone-Button-is-Incorect-Size.yml b/changelogs/unreleased/225393-Close-Milestone-Button-is-Incorect-Size.yml new file mode 100644 index 00000000000..4cb9fa9a77e --- /dev/null +++ b/changelogs/unreleased/225393-Close-Milestone-Button-is-Incorect-Size.yml @@ -0,0 +1,5 @@ +--- +title: 'Milestone Dashboard: Move Gray Type Badge Next to the Milestone Title' +merge_request: 39617 +author: Kev @KevSlashNull +type: fixed diff --git a/changelogs/unreleased/230980-severity-in-incident-list.yml b/changelogs/unreleased/230980-severity-in-incident-list.yml new file mode 100644 index 00000000000..881452abf4b --- /dev/null +++ b/changelogs/unreleased/230980-severity-in-incident-list.yml @@ -0,0 +1,5 @@ +--- +title: Surface incident severity and icon in the Incident List table +merge_request: 40112 +author: +type: added diff --git a/changelogs/unreleased/233455-destroy-board-graphql.yml b/changelogs/unreleased/233455-destroy-board-graphql.yml new file mode 100644 index 00000000000..10b8ae86c30 --- /dev/null +++ b/changelogs/unreleased/233455-destroy-board-graphql.yml @@ -0,0 +1,5 @@ +--- +title: Destroy issue board via GraphQL +merge_request: 40930 +author: +type: added diff --git a/changelogs/unreleased/241970-fix-hover-style-in-darkmode.yml b/changelogs/unreleased/241970-fix-hover-style-in-darkmode.yml new file mode 100644 index 00000000000..b615a2f5e91 --- /dev/null +++ b/changelogs/unreleased/241970-fix-hover-style-in-darkmode.yml @@ -0,0 +1,5 @@ +--- +title: Fix todos hover style in dark mode +merge_request: 41122 +author: +type: fixed diff --git a/changelogs/unreleased/app-logger-11.yml b/changelogs/unreleased/app-logger-11.yml new file mode 100644 index 00000000000..77f16c50b62 --- /dev/null +++ b/changelogs/unreleased/app-logger-11.yml @@ -0,0 +1,5 @@ +--- +title: Use applogger in some files of auth/ldap dir +merge_request: 41061 +author: Rajendra Kadam +type: other diff --git a/config/feature_flags/development/block_anonymous_global_searches.yml b/config/feature_flags/development/block_anonymous_global_searches.yml new file mode 100644 index 00000000000..527e99ad8a8 --- /dev/null +++ b/config/feature_flags/development/block_anonymous_global_searches.yml @@ -0,0 +1,7 @@ +--- +name: block_anonymous_global_searches +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/41041 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/244276 +group: group::global search +type: development +default_enabled: false
\ No newline at end of file diff --git a/config/feature_flags/development/ci_jobs_finder_refactor.yml b/config/feature_flags/development/ci_jobs_finder_refactor.yml new file mode 100644 index 00000000000..13b8fa03477 --- /dev/null +++ b/config/feature_flags/development/ci_jobs_finder_refactor.yml @@ -0,0 +1,7 @@ +--- +name: ci_jobs_finder_refactor +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36622 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/245183 +group: group::continuous integration +type: development +default_enabled: false diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index bb43e61775b..adf2e46869e 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -154,6 +154,8 @@ - 2 - - merge - 5 +- - merge_request_cleanup_refs + - 1 - - merge_request_mergeability_check - 1 - - metrics_dashboard_prune_old_annotations diff --git a/doc/README.md b/doc/README.md index 7f5efaf213a..1038517a5cc 100644 --- a/doc/README.md +++ b/doc/README.md @@ -198,7 +198,7 @@ The following documentation relates to the DevOps **Create** stage: | Create topics - Merge Requests | Description | |:--------------------------------------------------------------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------| -| [Checking out merge requests locally](user/project/merge_requests/reviewing_and_managing_merge_requests.md#checkout-merge-requests-locally) | Tips for working with merge requests locally. | +| [Checking out merge requests locally](user/project/merge_requests/reviewing_and_managing_merge_requests.md#checkout-merge-requests-locally-through-the-head-ref) | Tips for working with merge requests locally. | | [Cherry-picking](user/project/merge_requests/cherry_pick_changes.md) | Use GitLab for cherry-picking changes. | | [Merge request thread resolution](user/discussions/index.md#moving-a-single-thread-to-a-new-issue) | Resolve threads, move threads in a merge request to an issue, and only allow merge requests to be merged if all threads are resolved. | | [Merge requests](user/project/merge_requests/index.md) | Merge request management. | diff --git a/doc/api/epic_links.md b/doc/api/epic_links.md index 8a9abf8f08c..19c8dc78aed 100644 --- a/doc/api/epic_links.md +++ b/doc/api/epic_links.md @@ -131,6 +131,7 @@ POST /groups/:id/epics/:epic_iid/epics | `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | | `epic_iid` | integer | yes | The internal ID of the (future parent) epic. | | `title` | string | yes | The title of a newly created epic. | +| `confidential` | boolean | no | Whether the epic should be confidential. Will be ignored if `confidential_epics` feature flag is disabled. Defaults to the confidentiality state of the parent epic. | ```shell curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/1/epics/5/epics?title=Newpic" diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 2ecc5deb68e..bcf35cfc7ba 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -1710,6 +1710,31 @@ type ClusterAgent { project: Project """ + Tokens associated with the cluster agent + """ + tokens( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): ClusterAgentTokenConnection + + """ Timestamp the cluster agent was updated """ updatedAt: Time @@ -1798,6 +1823,26 @@ type ClusterAgentToken { } """ +The connection type for ClusterAgentToken. +""" +type ClusterAgentTokenConnection { + """ + A list of edges. + """ + edges: [ClusterAgentTokenEdge] + + """ + A list of nodes. + """ + nodes: [ClusterAgentToken] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" Autogenerated input type of ClusterAgentTokenCreate """ input ClusterAgentTokenCreateInput { @@ -1868,6 +1913,21 @@ type ClusterAgentTokenDeletePayload { } """ +An edge in a connection. +""" +type ClusterAgentTokenEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: ClusterAgentToken +} + +""" Identifier of Clusters::Agent """ scalar ClustersAgentID @@ -4341,6 +4401,41 @@ enum DesignVersionEvent { } """ +Autogenerated input type of DestroyBoard +""" +input DestroyBoardInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The global ID of the board to destroy + """ + id: BoardID! +} + +""" +Autogenerated return type of DestroyBoard +""" +type DestroyBoardPayload { + """ + The board after mutation + """ + board: Board + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Errors encountered during execution of the mutation. + """ + errors: [String!]! +} + +""" Autogenerated input type of DestroyNote """ input DestroyNoteInput { @@ -10588,6 +10683,7 @@ type Mutation { designManagementDelete(input: DesignManagementDeleteInput!): DesignManagementDeletePayload designManagementMove(input: DesignManagementMoveInput!): DesignManagementMovePayload designManagementUpload(input: DesignManagementUploadInput!): DesignManagementUploadPayload + destroyBoard(input: DestroyBoardInput!): DestroyBoardPayload destroyNote(input: DestroyNoteInput!): DestroyNotePayload destroySnippet(input: DestroySnippetInput!): DestroySnippetPayload @@ -11777,6 +11873,16 @@ type Project { ): BoardConnection """ + Find a single cluster agent by name + """ + clusterAgent( + """ + Name of the cluster agent + """ + name: String! + ): ClusterAgent + + """ Cluster agents associated with the project """ clusterAgents( diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index b849a55e2e6..31e398867b9 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -4660,6 +4660,59 @@ "deprecationReason": null }, { + "name": "tokens", + "description": "Tokens associated with the cluster agent", + "args": [ + { + "name": "after", + "description": "Returns the elements in the list that come after the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "before", + "description": "Returns the elements in the list that come before the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "first", + "description": "Returns the first _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "last", + "description": "Returns the last _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ClusterAgentTokenConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "updatedAt", "description": "Timestamp the cluster agent was updated", "args": [ @@ -4941,6 +4994,73 @@ "possibleTypes": null }, { + "kind": "OBJECT", + "name": "ClusterAgentTokenConnection", + "description": "The connection type for ClusterAgentToken.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ClusterAgentTokenEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ClusterAgentToken", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pageInfo", + "description": "Information to aid in pagination.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PageInfo", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { "kind": "INPUT_OBJECT", "name": "ClusterAgentTokenCreateInput", "description": "Autogenerated input type of ClusterAgentTokenCreate", @@ -5145,6 +5265,51 @@ "possibleTypes": null }, { + "kind": "OBJECT", + "name": "ClusterAgentTokenEdge", + "description": "An edge in a connection.", + "fields": [ + { + "name": "cursor", + "description": "A cursor for use in pagination.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "node", + "description": "The item at the end of the edge.", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "ClusterAgentToken", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { "kind": "SCALAR", "name": "ClustersAgentID", "description": "Identifier of Clusters::Agent", @@ -12001,6 +12166,108 @@ }, { "kind": "INPUT_OBJECT", + "name": "DestroyBoardInput", + "description": "Autogenerated input type of DestroyBoard", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": "The global ID of the board to destroy", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BoardID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DestroyBoardPayload", + "description": "Autogenerated return type of DestroyBoard", + "fields": [ + { + "name": "board", + "description": "The board after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Board", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Errors encountered during execution of the mutation.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", "name": "DestroyNoteInput", "description": "Autogenerated input type of DestroyNote", "fields": null, @@ -30391,6 +30658,33 @@ "deprecationReason": null }, { + "name": "destroyBoard", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyBoardInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DestroyBoardPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "destroyNote", "description": null, "args": [ @@ -35051,6 +35345,33 @@ "deprecationReason": null }, { + "name": "clusterAgent", + "description": "Find a single cluster agent by name", + "args": [ + { + "name": "name", + "description": "Name of the cluster agent", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ClusterAgent", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "clusterAgents", "description": "Cluster agents associated with the project", "args": [ diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 98550241f7e..5ae0d747048 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -728,6 +728,16 @@ A specific version in which designs were added, modified or deleted | `id` | ID! | ID of the design version | | `sha` | ID! | SHA of the design version | +### DestroyBoardPayload + +Autogenerated return type of DestroyBoard + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `board` | Board | The board after mutation | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Errors encountered during execution of the mutation. | + ### DestroyNotePayload Autogenerated return type of DestroyNote @@ -1771,6 +1781,7 @@ Autogenerated return type of PipelineRetry | `autocloseReferencedIssues` | Boolean | Indicates if issues referenced by merge requests and commits within the default branch are closed automatically | | `avatarUrl` | String | URL to avatar image file of the project | | `board` | Board | A single board of the project | +| `clusterAgent` | ClusterAgent | Find a single cluster agent by name | | `containerExpirationPolicy` | ContainerExpirationPolicy | The container expiration policy of the project | | `containerRegistryEnabled` | Boolean | Indicates if the project stores Docker container images in a container registry | | `createdAt` | Time | Timestamp of the project creation | diff --git a/doc/development/telemetry/usage_ping.md b/doc/development/telemetry/usage_ping.md index 6c432ab3d87..04206e1fae0 100644 --- a/doc/development/telemetry/usage_ping.md +++ b/doc/development/telemetry/usage_ping.md @@ -316,6 +316,10 @@ Implemented using Redis methods [PFADD](https://redis.io/commands/pfadd) and [PF Increment unique users count using Redis HLL, for given event name. + Tracking events using the `UsageData` API requires the `usage_data_api` feature flag to be enabled, which is disabled by default. + + API requests are protected by checking of a valid CSRF token. + In order to be able to increment the values the related feature `usage_data<event_name>` should be enabled. ```plaintext @@ -330,9 +334,10 @@ Implemented using Redis methods [PFADD](https://redis.io/commands/pfadd) and [PF Return 200 if tracking failed for any reason. - - `401 Unauthorized` if user is not authenticated - - `400 Bad request` if event parameter is missing - `200` if event was tracked or any errors + - `400 Bad request` if event parameter is missing + - `401 Unauthorized` if user is not authenticated + - `403 Forbidden` for invalid CSRF token provided 1. Track event using base module `Gitlab::UsageDataCounters::HLLRedisCounter.track_event(entity_id, event_name)`. diff --git a/doc/operations/feature_flags.md b/doc/operations/feature_flags.md index c91c150061d..f517596f0d6 100644 --- a/doc/operations/feature_flags.md +++ b/doc/operations/feature_flags.md @@ -54,22 +54,6 @@ To create and enable a feature flag: You can change these settings by clicking the **{pencil}** (edit) button next to any feature flag in the list. -## Rollout strategy (legacy) - -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/8240) in GitLab 12.2. - -In GitLab 13.0 and earlier, the **Rollout strategy** setting affects which users will experience -the feature as enabled. Choose the percentage of users that the feature will be enabled -for. The rollout strategy will have no effect if the environment spec is disabled. - -It can be set to: - -- All users -- [Percent of users](#percent-of-users) - - Optionally, you can click the **Include additional user IDs** checkbox and add a list - of specific users IDs to enable the feature for. -- [User IDs](#user-ids) - ## Feature flag strategies > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/35555) in GitLab 13.0. @@ -208,6 +192,23 @@ To enable it: Feature.enable(:feature_flags_new_version) ``` +## Rollout strategy (legacy) + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/8240) in GitLab 12.2. +> - [Made read-only](https://gitlab.com/gitlab-org/gitlab/-/issues/220228) in GitLab 13.4. + +In GitLab 13.0 and earlier, the **Rollout strategy** setting affects which users will experience +the feature as enabled. Choose the percentage of users that the feature will be enabled +for. The rollout strategy will have no effect if the environment spec is disabled. + +It can be set to: + +- All users +- [Percent of users](#percent-of-users) + - Optionally, you can click the **Include additional user IDs** checkbox and add a list + of specific users IDs to enable the feature for. +- [User IDs](#user-ids) + ## Disable a feature flag for a specific environment In [GitLab 13.0 and earlier](https://gitlab.com/gitlab-org/gitlab/-/issues/8621), diff --git a/doc/operations/incident_management/img/incident_list_sort_v13_3.png b/doc/operations/incident_management/img/incident_list_sort_v13_3.png Binary files differdeleted file mode 100644 index 4a263aa188e..00000000000 --- a/doc/operations/incident_management/img/incident_list_sort_v13_3.png +++ /dev/null diff --git a/doc/operations/incident_management/img/incident_list_v13_4.png b/doc/operations/incident_management/img/incident_list_v13_4.png Binary files differnew file mode 100644 index 00000000000..bf00e630c67 --- /dev/null +++ b/doc/operations/incident_management/img/incident_list_v13_4.png diff --git a/doc/operations/incident_management/incidents.md b/doc/operations/incident_management/incidents.md index 2a0910a1d9f..5af311c693a 100644 --- a/doc/operations/incident_management/incidents.md +++ b/doc/operations/incident_management/incidents.md @@ -13,12 +13,18 @@ For users with at least Developer [permissions](../../user/permissions.md), the Incident Management list is available at **Operations > Incidents** in your project's sidebar. The list contains the following metrics: -![Incident List](./img/incident_list_sort_v13_3.png) +![Incident List](img/incident_list_v13_4.png) - **Status** - To filter incidents by their status, click **Open**, **Closed**, or **All** above the incident list. - **Search** - The Incident list supports a simple free text search, which filters on the **Title** and **Incident** fields. +- **Severity** - Severity of a particular incident. Can have one of the following values: + - `Critical - S1` + - `High - S2` + - `Medium - S3` + - `Low - S4` + - `Unknown` - **Incident** - The description of the incident, which attempts to capture the most meaningful data. - **Date created** - How long ago the incident was created. This field uses the diff --git a/doc/user/application_security/security_dashboard/img/group_vulnerability_report_v13_3.png b/doc/user/application_security/security_dashboard/img/group_vulnerability_report_v13_3.png Binary files differdeleted file mode 100644 index 7b9a48b8738..00000000000 --- a/doc/user/application_security/security_dashboard/img/group_vulnerability_report_v13_3.png +++ /dev/null diff --git a/doc/user/application_security/security_dashboard/img/group_vulnerability_report_v13_4.png b/doc/user/application_security/security_dashboard/img/group_vulnerability_report_v13_4.png Binary files differnew file mode 100644 index 00000000000..67a7bb5f368 --- /dev/null +++ b/doc/user/application_security/security_dashboard/img/group_vulnerability_report_v13_4.png diff --git a/doc/user/application_security/security_dashboard/index.md b/doc/user/application_security/security_dashboard/index.md index 6535ec71811..07b4eef906f 100644 --- a/doc/user/application_security/security_dashboard/index.md +++ b/doc/user/application_security/security_dashboard/index.md @@ -196,7 +196,7 @@ to configure daily security scans. Each dashboard's vulnerability list contains vulnerabilities from the latest scans that were merged into the default branch. -![Vulnerability Report](img/group_vulnerability_report_v13_3.png) +![Vulnerability Report](img/group_vulnerability_report_v13_4.png) You can filter which vulnerabilities the Security Dashboard displays by: diff --git a/doc/user/project/merge_requests/reviewing_and_managing_merge_requests.md b/doc/user/project/merge_requests/reviewing_and_managing_merge_requests.md index f60be893e45..6d181bcc822 100644 --- a/doc/user/project/merge_requests/reviewing_and_managing_merge_requests.md +++ b/doc/user/project/merge_requests/reviewing_and_managing_merge_requests.md @@ -284,15 +284,26 @@ the command line. NOTE: **Note:** This section might move in its own document in the future. -### Checkout merge requests locally +### Checkout merge requests locally through the `head` ref A merge request contains all the history from a repository, plus the additional commits added to the branch associated with the merge request. Here's a few -tricks to checkout a merge request locally. +ways to checkout a merge request locally. Please note that you can checkout a merge request locally even if the source project is a fork (even a private fork) of the target project. +This relies on the merge request `head` ref (`refs/merge-requests/:iid/head`) +that is available for each merge request. It allows checking out a merge +request via its ID instead of its branch. + +[Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/223156) in GitLab +13.4, 14 days after a merge request gets closed or merged, the merge request +`head` ref will be deleted. This means that the merge request will not be available +for local checkout via the merge request `head` ref anymore. The merge request +can still be re-opened. Also, as long as the merge request's branch +exists, you can still check out the branch as it won't be affected. + #### Checkout locally by adding a Git alias Add the following alias to your `~/.gitconfig`: diff --git a/lib/api/ci/pipelines.rb b/lib/api/ci/pipelines.rb index 55089045e80..045f81074a7 100644 --- a/lib/api/ci/pipelines.rb +++ b/lib/api/ci/pipelines.rb @@ -15,6 +15,24 @@ module API detail 'This feature was introduced in GitLab 8.11.' success Entities::Ci::PipelineBasic end + + helpers do + params :optional_scope do + optional :scope, types: [String, Array[String]], desc: 'The scope of builds to show', + values: ::CommitStatus::AVAILABLE_STATUSES, + coerce_with: ->(scope) { + case scope + when String + [scope] + when ::Array + scope + else + ['unknown'] + end + } + end + end + params do use :pagination optional :scope, type: String, values: %w[running pending finished branches tags], @@ -96,6 +114,64 @@ module API present pipeline, with: Entities::Ci::Pipeline end + desc 'Get pipeline jobs' do + success Entities::Ci::Job + end + params do + requires :pipeline_id, type: Integer, desc: 'The pipeline ID' + use :optional_scope + use :pagination + end + + get ':id/pipelines/:pipeline_id/jobs' do + authorize!(:read_pipeline, user_project) + + pipeline = user_project.all_pipelines.find(params[:pipeline_id]) + + if Feature.enabled?(:ci_jobs_finder_refactor) + builds = ::Ci::JobsFinder + .new(current_user: current_user, pipeline: pipeline, params: params) + .execute + else + authorize!(:read_build, pipeline) + builds = pipeline.builds + builds = filter_builds(builds, params[:scope]) + end + + builds = builds.with_preloads + + present paginate(builds), with: Entities::Ci::Job + end + + desc 'Get pipeline bridge jobs' do + success Entities::Ci::Bridge + end + params do + requires :pipeline_id, type: Integer, desc: 'The pipeline ID' + use :optional_scope + use :pagination + end + + get ':id/pipelines/:pipeline_id/bridges' do + authorize!(:read_build, user_project) + + pipeline = user_project.all_pipelines.find(params[:pipeline_id]) + + if Feature.enabled?(:ci_jobs_finder_refactor) + bridges = ::Ci::JobsFinder + .new(current_user: current_user, pipeline: pipeline, params: params, type: ::Ci::Bridge) + .execute + else + authorize!(:read_pipeline, pipeline) + bridges = pipeline.bridges + bridges = filter_builds(bridges, params[:scope]) + end + + bridges = bridges.with_preloads + + present paginate(bridges), with: Entities::Ci::Bridge + end + desc 'Gets the variables for a given pipeline' do detail 'This feature was introduced in GitLab 11.11' success Entities::Ci::Variable @@ -170,6 +246,21 @@ module API end helpers do + # NOTE: This method should be removed once the ci_jobs_finder_refactor FF is + # removed. https://gitlab.com/gitlab-org/gitlab/-/issues/245183 + # rubocop: disable CodeReuse/ActiveRecord + def filter_builds(builds, scope) + return builds if scope.nil? || scope.empty? + + available_statuses = ::CommitStatus::AVAILABLE_STATUSES + + unknown = scope - available_statuses + render_api_error!('Scope contains invalid value(s)', 400) unless unknown.empty? + + builds.where(status: scope) + end + # rubocop: enable CodeReuse/ActiveRecord + def pipeline strong_memoize(:pipeline) do user_project.all_pipelines.find(params[:pipeline_id]) diff --git a/lib/api/jobs.rb b/lib/api/jobs.rb index 084c146abe7..ad46d948f3b 100644 --- a/lib/api/jobs.rb +++ b/lib/api/jobs.rb @@ -48,54 +48,6 @@ module API end # rubocop: enable CodeReuse/ActiveRecord - desc 'Get pipeline jobs' do - success Entities::Ci::Job - end - params do - requires :pipeline_id, type: Integer, desc: 'The pipeline ID' - use :optional_scope - use :pagination - end - # rubocop: disable CodeReuse/ActiveRecord - get ':id/pipelines/:pipeline_id/jobs' do - authorize!(:read_pipeline, user_project) - pipeline = user_project.all_pipelines.find(params[:pipeline_id]) - authorize!(:read_build, pipeline) - - builds = pipeline.builds - builds = filter_builds(builds, params[:scope]) - builds = builds.preload(:job_artifacts_archive, :job_artifacts, project: [:namespace]) - - present paginate(builds), with: Entities::Ci::Job - end - # rubocop: enable CodeReuse/ActiveRecord - - desc 'Get pipeline bridge jobs' do - success ::API::Entities::Ci::Bridge - end - params do - requires :pipeline_id, type: Integer, desc: 'The pipeline ID' - use :optional_scope - use :pagination - end - # rubocop: disable CodeReuse/ActiveRecord - get ':id/pipelines/:pipeline_id/bridges' do - authorize!(:read_build, user_project) - pipeline = user_project.ci_pipelines.find(params[:pipeline_id]) - authorize!(:read_pipeline, pipeline) - - bridges = pipeline.bridges - bridges = filter_builds(bridges, params[:scope]) - bridges = bridges.preload( - :metadata, - downstream_pipeline: [project: [:route, { namespace: :route }]], - project: [:namespace] - ) - - present paginate(bridges), with: ::API::Entities::Ci::Bridge - end - # rubocop: enable CodeReuse/ActiveRecord - desc 'Get a specific job of a project' do success Entities::Ci::Job end diff --git a/lib/api/usage_data.rb b/lib/api/usage_data.rb index 05a6f45b21f..a1512197ee1 100644 --- a/lib/api/usage_data.rb +++ b/lib/api/usage_data.rb @@ -7,6 +7,7 @@ module API namespace 'usage_data' do before do not_found! unless Feature.enabled?(:usage_data_api) + forbidden!('Invalid CSRF token is provided') unless verified_request? end desc 'Track usage data events' do diff --git a/lib/gitlab/auth/ldap/adapter.rb b/lib/gitlab/auth/ldap/adapter.rb index 4f448211abf..b7bb61f0677 100644 --- a/lib/gitlab/auth/ldap/adapter.rb +++ b/lib/gitlab/auth/ldap/adapter.rb @@ -55,7 +55,7 @@ module Gitlab response = ldap.get_operation_result unless response.code == 0 - Rails.logger.warn("LDAP search error: #{response.message}") # rubocop:disable Gitlab/RailsLogger + Gitlab::AppLogger.warn("LDAP search error: #{response.message}") end [] @@ -67,7 +67,7 @@ module Gitlab retries += 1 error_message = connection_error_message(error) - Rails.logger.warn(error_message) # rubocop:disable Gitlab/RailsLogger + Gitlab::AppLogger.warn(error_message) if retries < MAX_SEARCH_RETRIES renew_connection_adapter diff --git a/lib/gitlab/shell.rb b/lib/gitlab/shell.rb index 24f49f6b943..3419989c110 100644 --- a/lib/gitlab/shell.rb +++ b/lib/gitlab/shell.rb @@ -116,7 +116,7 @@ module Gitlab true rescue => e - Rails.logger.warn("Repository does not exist: #{e} at: #{disk_path}.git") # rubocop:disable Gitlab/RailsLogger + Gitlab::AppLogger.warn("Repository does not exist: #{e} at: #{disk_path}.git") Gitlab::ErrorTracking.track_exception(e, path: disk_path, storage: storage) false diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 8695d93e7d8..8d26eb84b54 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -13345,6 +13345,9 @@ msgstr "" msgid "IncidentManagement|Published to status page" msgstr "" +msgid "IncidentManagement|Severity" +msgstr "" + msgid "IncidentManagement|There are no closed incidents" msgstr "" @@ -28952,6 +28955,9 @@ msgstr "" msgid "You must accept our Terms of Service and privacy policy in order to register an account" msgstr "" +msgid "You must be logged in to search across all of GitLab" +msgstr "" + msgid "You must disassociate %{domain} from all clusters it is attached to before deletion." msgstr "" diff --git a/qa/qa/page/main/sign_up.rb b/qa/qa/page/main/sign_up.rb index c47d2ce9c74..6a1408f7fc7 100644 --- a/qa/qa/page/main/sign_up.rb +++ b/qa/qa/page/main/sign_up.rb @@ -14,6 +14,10 @@ module QA element :new_user_accept_terms_checkbox end + view 'ee/app/views/registrations/welcome/_button.html.haml' do + element :get_started_button + end + def sign_up!(user) fill_element :new_user_name_field, user.name fill_element :new_user_username_field, user.username @@ -24,7 +28,9 @@ module QA check_element :new_user_accept_terms_checkbox if has_element?(:new_user_accept_terms_checkbox) signed_in = retry_until do - click_element :new_user_register_button + click_element :new_user_register_button if has_element?(:new_user_register_button) + + click_element :get_started_button if has_element?(:get_started_button) Page::Main::Menu.perform(&:has_personal_area?) end diff --git a/rubocop/cop/gitlab/rails_logger.rb b/rubocop/cop/gitlab/rails_logger.rb index d1a06a9a100..ad35d2ccfbb 100644 --- a/rubocop/cop/gitlab/rails_logger.rb +++ b/rubocop/cop/gitlab/rails_logger.rb @@ -8,7 +8,7 @@ module RuboCop class RailsLogger < ::RuboCop::Cop::Cop include CodeReuseHelpers - # This cop checks for the Rails.logger in the codebase + # This cop checks for the Rails.logger log methods in the codebase # # @example # @@ -17,34 +17,29 @@ module RuboCop # # # good # Gitlab::AppLogger.error("Project %{project_path} could not be saved" % { project_path: project.full_path }) + # + # # OK + # Rails.logger.level MSG = 'Use a structured JSON logger instead of `Rails.logger`. ' \ 'https://docs.gitlab.com/ee/development/logging.html'.freeze - def_node_matcher :rails_logger?, <<~PATTERN - (send (const nil? :Rails) :logger ... ) - PATTERN + # See supported log methods: + # https://ruby-doc.org/stdlib-2.6.6/libdoc/logger/rdoc/Logger.html + LOG_METHODS = %i[debug error fatal info warn].freeze + LOG_METHODS_PATTERN = LOG_METHODS.map(&:inspect).join(' ').freeze - WHITELISTED_DIRECTORIES = %w[ - spec - ].freeze + def_node_matcher :rails_logger_log?, <<~PATTERN + (send + (send (const nil? :Rails) :logger) + {#{LOG_METHODS_PATTERN}} ... + ) + PATTERN def on_send(node) - return if in_whitelisted_directory?(node) - return unless rails_logger?(node) + return unless rails_logger_log?(node) add_offense(node, location: :expression) end - - def in_whitelisted_directory?(node) - path = file_path_for_node(node) - - WHITELISTED_DIRECTORIES.any? do |directory| - path.start_with?( - File.join(rails_root, directory), - File.join(rails_root, 'ee', directory) - ) - end - end end end end diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb index e85330b08f6..f244392bbad 100644 --- a/spec/controllers/search_controller_spec.rb +++ b/spec/controllers/search_controller_spec.rb @@ -95,49 +95,77 @@ RSpec.describe SearchController do using RSpec::Parameterized::TableSyntax render_views - it 'omits pipeline status from load' do - project = create(:project, :public) - expect(Gitlab::Cache::Ci::ProjectPipelineStatus).not_to receive(:load_in_batch_for_projects) + context 'when block_anonymous_global_searches is disabled' do + before do + stub_feature_flags(block_anonymous_global_searches: false) + end - get :show, params: { scope: 'projects', search: project.name } + it 'omits pipeline status from load' do + project = create(:project, :public) + expect(Gitlab::Cache::Ci::ProjectPipelineStatus).not_to receive(:load_in_batch_for_projects) - expect(assigns[:search_objects].first).to eq project - end + get :show, params: { scope: 'projects', search: project.name } - context 'check search term length' do - let(:search_queries) do - char_limit = SearchService::SEARCH_CHAR_LIMIT - term_limit = SearchService::SEARCH_TERM_LIMIT - { - chars_under_limit: ('a' * (char_limit - 1)), - chars_over_limit: ('a' * (char_limit + 1)), - terms_under_limit: ('abc ' * (term_limit - 1)), - terms_over_limit: ('abc ' * (term_limit + 1)) - } + expect(assigns[:search_objects].first).to eq project end - where(:string_name, :expectation) do - :chars_under_limit | :not_to_set_flash - :chars_over_limit | :set_chars_flash - :terms_under_limit | :not_to_set_flash - :terms_over_limit | :set_terms_flash - end + context 'check search term length' do + let(:search_queries) do + char_limit = SearchService::SEARCH_CHAR_LIMIT + term_limit = SearchService::SEARCH_TERM_LIMIT + { + chars_under_limit: ('a' * (char_limit - 1)), + chars_over_limit: ('a' * (char_limit + 1)), + terms_under_limit: ('abc ' * (term_limit - 1)), + terms_over_limit: ('abc ' * (term_limit + 1)) + } + end - with_them do - it do - get :show, params: { scope: 'projects', search: search_queries[string_name] } - - case expectation - when :not_to_set_flash - expect(controller).not_to set_flash[:alert] - when :set_chars_flash - expect(controller).to set_flash[:alert].to(/characters/) - when :set_terms_flash - expect(controller).to set_flash[:alert].to(/terms/) + where(:string_name, :expectation) do + :chars_under_limit | :not_to_set_flash + :chars_over_limit | :set_chars_flash + :terms_under_limit | :not_to_set_flash + :terms_over_limit | :set_terms_flash + end + + with_them do + it do + get :show, params: { scope: 'projects', search: search_queries[string_name] } + + case expectation + when :not_to_set_flash + expect(controller).not_to set_flash[:alert] + when :set_chars_flash + expect(controller).to set_flash[:alert].to(/characters/) + when :set_terms_flash + expect(controller).to set_flash[:alert].to(/terms/) + end end end end end + + context 'when block_anonymous_global_searches is enabled' do + context 'for unauthenticated user' do + before do + sign_out(user) + end + + it 'redirects to login page' do + get :show, params: { scope: 'projects', search: '*' } + + expect(response).to redirect_to new_user_session_path + end + end + + context 'for authenticated user' do + it 'succeeds' do + get :show, params: { scope: 'projects', search: '*' } + + expect(response).to have_gitlab_http_status(:ok) + end + end + end end it 'finds issue comments' do diff --git a/spec/features/issues/user_creates_issue_spec.rb b/spec/features/issues/user_creates_issue_spec.rb index c00ba558a7b..668b4265948 100644 --- a/spec/features/issues/user_creates_issue_spec.rb +++ b/spec/features/issues/user_creates_issue_spec.rb @@ -61,6 +61,10 @@ RSpec.describe "User creates issue" do .and have_content(project.name) expect(page).to have_selector('strong', text: 'Description') end + + it 'does not render the issue type dropdown' do + expect(page).not_to have_selector('.s-issuable-type-filter-dropdown-wrap') + end end context "when signed in as developer", :js do diff --git a/spec/features/search/user_searches_for_issues_spec.rb b/spec/features/search/user_searches_for_issues_spec.rb index d895e87f1cb..900ed35adea 100644 --- a/spec/features/search/user_searches_for_issues_spec.rb +++ b/spec/features/search/user_searches_for_issues_spec.rb @@ -86,20 +86,33 @@ RSpec.describe 'User searches for issues', :js do end context 'when signed out' do - let(:project) { create(:project, :public) } + context 'when block_anonymous_global_searches is disabled' do + let(:project) { create(:project, :public) } - before do - visit(search_path) - end + before do + stub_feature_flags(block_anonymous_global_searches: false) + visit(search_path) + end - include_examples 'top right search form' + include_examples 'top right search form' - it 'finds an issue' do - search_for_issue(issue1.title) + it 'finds an issue' do + search_for_issue(issue1.title) - page.within('.results') do - expect(page).to have_link(issue1.title) - expect(page).not_to have_link(issue2.title) + page.within('.results') do + expect(page).to have_link(issue1.title) + expect(page).not_to have_link(issue2.title) + end + end + end + + context 'when block_anonymous_global_searches is enabled' do + before do + visit(search_path) + end + + it 'is redirected to login page' do + expect(page).to have_content('You must be logged in to search across all of GitLab') end end end diff --git a/spec/features/search/user_searches_for_projects_spec.rb b/spec/features/search/user_searches_for_projects_spec.rb index 7bb5a4da7d0..b64909dd42f 100644 --- a/spec/features/search/user_searches_for_projects_spec.rb +++ b/spec/features/search/user_searches_for_projects_spec.rb @@ -6,31 +6,44 @@ RSpec.describe 'User searches for projects' do let!(:project) { create(:project, :public, name: 'Shop') } context 'when signed out' do - include_examples 'top right search form' + context 'when block_anonymous_global_searches is disabled' do + before do + stub_feature_flags(block_anonymous_global_searches: false) + end - it 'finds a project' do - visit(search_path) + include_examples 'top right search form' - fill_in('dashboard_search', with: project.name[0..3]) - click_button('Search') + it 'finds a project' do + visit(search_path) - expect(page).to have_link(project.name) - end + fill_in('dashboard_search', with: project.name[0..3]) + click_button('Search') - it 'preserves the group being searched in' do - visit(search_path(group_id: project.namespace.id)) + expect(page).to have_link(project.name) + end - submit_search('foo') + it 'preserves the group being searched in' do + visit(search_path(group_id: project.namespace.id)) - expect(find('#group_id', visible: false).value).to eq(project.namespace.id.to_s) - end + submit_search('foo') + + expect(find('#group_id', visible: false).value).to eq(project.namespace.id.to_s) + end - it 'preserves the project being searched in' do - visit(search_path(project_id: project.id)) + it 'preserves the project being searched in' do + visit(search_path(project_id: project.id)) - submit_search('foo') + submit_search('foo') + + expect(find('#project_id', visible: false).value).to eq(project.id.to_s) + end + end - expect(find('#project_id', visible: false).value).to eq(project.id.to_s) + context 'when block_anonymous_global_searches is enabled' do + it 'is redirected to login page' do + visit(search_path) + expect(page).to have_content('You must be logged in to search across all of GitLab') + end end end end diff --git a/spec/finders/ci/jobs_finder_spec.rb b/spec/finders/ci/jobs_finder_spec.rb index e6680afa15c..a6a41c36489 100644 --- a/spec/finders/ci/jobs_finder_spec.rb +++ b/spec/finders/ci/jobs_finder_spec.rb @@ -36,53 +36,135 @@ RSpec.describe Ci::JobsFinder, '#execute' do end end - context 'scope is present' do - let(:jobs) { [job_1, job_2, job_3] } - - where(:scope, :index) do - [ - ['pending', 0], - ['running', 1], - ['finished', 2] - ] + context 'with ci_jobs_finder_refactor ff enabled' do + before do + stub_feature_flags(ci_jobs_finder_refactor: true) end - with_them do - let(:params) { { scope: scope } } + context 'scope is present' do + let(:jobs) { [job_1, job_2, job_3] } + + where(:scope, :index) do + [ + ['pending', 0], + ['running', 1], + ['finished', 2] + ] + end + + with_them do + let(:params) { { scope: scope } } - it { expect(subject).to match_array([jobs[index]]) } + it { expect(subject).to match_array([jobs[index]]) } + end end - end - end - context 'a project is present' do - subject { described_class.new(current_user: user, project: project, params: params).execute } + context 'scope is an array' do + let(:jobs) { [job_1, job_2, job_3] } + let(:params) {{ scope: ['running'] }} - context 'user has access to the project' do + it 'filters by the job statuses in the scope' do + expect(subject).to match_array([job_2]) + end + end + end + + context 'with ci_jobs_finder_refactor ff disabled' do before do - project.add_maintainer(user) + stub_feature_flags(ci_jobs_finder_refactor: false) end - it 'returns jobs for the specified project' do - expect(subject).to match_array([job_3]) + context 'scope is present' do + let(:jobs) { [job_1, job_2, job_3] } + + where(:scope, :index) do + [ + ['pending', 0], + ['running', 1], + ['finished', 2] + ] + end + + with_them do + let(:params) { { scope: scope } } + + it { expect(subject).to match_array([jobs[index]]) } + end end end + end - context 'user has no access to project builds' do - before do - project.add_guest(user) + context 'with ci_jobs_finder_refactor ff enabled' do + before do + stub_feature_flags(ci_jobs_finder_refactor: true) + end + + context 'a project is present' do + subject { described_class.new(current_user: user, project: project, params: params).execute } + + context 'user has access to the project' do + before do + project.add_maintainer(user) + end + + it 'returns jobs for the specified project' do + expect(subject).to match_array([job_3]) + end end - it 'returns no jobs' do - expect(subject).to be_empty + context 'user has no access to project builds' do + before do + project.add_guest(user) + end + + it 'returns no jobs' do + expect(subject).to be_empty + end + end + + context 'without user' do + let(:user) { nil } + + it 'returns no jobs' do + expect(subject).to be_empty + end end end + end - context 'without user' do - let(:user) { nil } + context 'with ci_jobs_finder_refactor ff disabled' do + before do + stub_feature_flags(ci_jobs_finder_refactor: false) + end + context 'a project is present' do + subject { described_class.new(current_user: user, project: project, params: params).execute } - it 'returns no jobs' do - expect(subject).to be_empty + context 'user has access to the project' do + before do + project.add_maintainer(user) + end + + it 'returns jobs for the specified project' do + expect(subject).to match_array([job_3]) + end + end + + context 'user has no access to project builds' do + before do + project.add_guest(user) + end + + it 'returns no jobs' do + expect(subject).to be_empty + end + end + + context 'without user' do + let(:user) { nil } + + it 'returns no jobs' do + expect(subject).to be_empty + end end end end diff --git a/spec/frontend/fixtures/search.rb b/spec/frontend/fixtures/search.rb index fcd68662acc..40f613a9422 100644 --- a/spec/frontend/fixtures/search.rb +++ b/spec/frontend/fixtures/search.rb @@ -7,10 +7,16 @@ RSpec.describe SearchController, '(JavaScript fixtures)', type: :controller do render_views + let_it_be(:user) { create(:admin) } + before(:all) do clean_frontend_fixtures('search/') end + before do + sign_in(user) + end + it 'search/show.html' do get :show diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js index 702937d61a7..307806e0a8a 100644 --- a/spec/frontend/incidents/components/incidents_list_spec.js +++ b/spec/frontend/incidents/components/incidents_list_spec.js @@ -13,6 +13,7 @@ import { } from '@gitlab/ui'; import { visitUrl, joinPaths, mergeUrlParams } from '~/lib/utils/url_utility'; import IncidentsList from '~/incidents/components/incidents_list.vue'; +import SeverityToken from '~/sidebar/components/severity/severity.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { I18N, INCIDENT_STATUS_TABS } from '~/incidents/constants'; import mockIncidents from '../mocks/incidents.json'; @@ -51,6 +52,7 @@ describe('Incidents List', () => { const findStatusFilterBadge = () => wrapper.findAll(GlBadge); const findStatusTabs = () => wrapper.find(GlTabs); const findEmptyState = () => wrapper.find(GlEmptyState); + const findSeverity = () => wrapper.findAll(SeverityToken); function mountComponent({ data = { incidents: [], incidentsCount: {} }, loading = false }) { wrapper = mount(IncidentsList, { @@ -182,6 +184,10 @@ describe('Incidents List', () => { ); }); }); + + it('renders severity per row', () => { + expect(findSeverity().length).toBe(mockIncidents.length); + }); }); describe('Create Incident', () => { diff --git a/spec/frontend/incidents/mocks/incidents.json b/spec/frontend/incidents/mocks/incidents.json index 4eab709e53f..42b3d6d3eb6 100644 --- a/spec/frontend/incidents/mocks/incidents.json +++ b/spec/frontend/incidents/mocks/incidents.json @@ -4,7 +4,8 @@ "title": "New: Incident", "createdAt": "2020-06-03T15:46:08Z", "assignees": {}, - "state": "opened" + "state": "opened", + "severity": "CRITICAL" }, { "iid": "14", @@ -20,20 +21,23 @@ } ] }, - "state": "opened" + "state": "opened", + "severity": "HIGH" }, { "iid": "13", "title": "Create issue3", "createdAt": "2020-05-19T08:53:55Z", "assignees": {}, - "state": "closed" + "state": "closed", + "severity": "LOW" }, { "iid": "12", "title": "Create issue2", "createdAt": "2020-05-18T17:13:35Z", "assignees": {}, - "state": "closed" + "state": "closed", + "severity": "MEDIUM" } ] diff --git a/spec/lib/gitlab/auth/ldap/adapter_spec.rb b/spec/lib/gitlab/auth/ldap/adapter_spec.rb index 78970378b7f..8546d63cf77 100644 --- a/spec/lib/gitlab/auth/ldap/adapter_spec.rb +++ b/spec/lib/gitlab/auth/ldap/adapter_spec.rb @@ -128,7 +128,7 @@ RSpec.describe Gitlab::Auth::Ldap::Adapter do before do allow(adapter).to receive(:renew_connection_adapter).and_return(ldap) allow(ldap).to receive(:search) { raise Net::LDAP::Error, "some error" } - allow(Rails.logger).to receive(:warn) + allow(Gitlab::AppLogger).to receive(:warn) end context 'retries the operation' do @@ -152,7 +152,7 @@ RSpec.describe Gitlab::Auth::Ldap::Adapter do it 'logs the error' do expect { subject }.to raise_error(Gitlab::Auth::Ldap::LdapConnectionError) - expect(Rails.logger).to have_received(:warn).with( + expect(Gitlab::AppLogger).to have_received(:warn).with( "LDAP search raised exception Net::LDAP::Error: some error") end end diff --git a/spec/models/clusters/agent_spec.rb b/spec/models/clusters/agent_spec.rb index bb1fc021e66..99de0d1ddf7 100644 --- a/spec/models/clusters/agent_spec.rb +++ b/spec/models/clusters/agent_spec.rb @@ -12,6 +12,17 @@ RSpec.describe Clusters::Agent do it { is_expected.to validate_length_of(:name).is_at_most(63) } it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) } + describe 'scopes' do + describe '.with_name' do + let!(:matching_name) { create(:cluster_agent, name: 'matching-name') } + let!(:other_name) { create(:cluster_agent, name: 'other-name') } + + subject { described_class.with_name(matching_name.name) } + + it { is_expected.to contain_exactly(matching_name) } + end + end + describe 'validation' do describe 'name validation' do it 'rejects names that do not conform to RFC 1123', :aggregate_failures do diff --git a/spec/requests/api/ci/pipelines_spec.rb b/spec/requests/api/ci/pipelines_spec.rb index 3b611b2bb9f..577b43e6e42 100644 --- a/spec/requests/api/ci/pipelines_spec.rb +++ b/spec/requests/api/ci/pipelines_spec.rb @@ -5,6 +5,7 @@ require 'spec_helper' RSpec.describe API::Ci::Pipelines do let_it_be(:user) { create(:user) } let_it_be(:non_member) { create(:user) } + let_it_be(:project2) { create(:project, creator: user) } # We need to reload as the shared example 'pipelines visibility table' is changing project let_it_be(:project, reload: true) do @@ -307,6 +308,606 @@ RSpec.describe API::Ci::Pipelines do end end + describe 'GET /projects/:id/pipelines/:pipeline_id/jobs' do + let(:query) { {} } + let(:api_user) { user } + let_it_be(:job) do + create(:ci_build, :success, pipeline: pipeline, + artifacts_expire_at: 1.day.since) + end + + let(:guest) { create(:project_member, :guest, project: project).user } + + before do |example| + unless example.metadata[:skip_before_request] + project.update!(public_builds: false) + get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query + end + end + + context 'with ci_jobs_finder_refactor ff enabled' do + before do + stub_feature_flags(ci_jobs_finder_refactor: true) + end + + context 'authorized user' do + it 'returns pipeline jobs' do + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + end + + it 'returns correct values' do + expect(json_response).not_to be_empty + expect(json_response.first['commit']['id']).to eq project.commit.id + expect(Time.parse(json_response.first['artifacts_expire_at'])).to be_like_time(job.artifacts_expire_at) + expect(json_response.first['artifacts_file']).to be_nil + expect(json_response.first['artifacts']).to be_an Array + expect(json_response.first['artifacts']).to be_empty + end + + it_behaves_like 'a job with artifacts and trace' do + let(:api_endpoint) { "/projects/#{project.id}/pipelines/#{pipeline.id}/jobs" } + end + + it 'returns pipeline data' do + json_job = json_response.first + + expect(json_job['pipeline']).not_to be_empty + expect(json_job['pipeline']['id']).to eq job.pipeline.id + expect(json_job['pipeline']['ref']).to eq job.pipeline.ref + expect(json_job['pipeline']['sha']).to eq job.pipeline.sha + expect(json_job['pipeline']['status']).to eq job.pipeline.status + end + + context 'filter jobs with one scope element' do + let(:query) { { 'scope' => 'pending' } } + + it do + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Array + end + end + + context 'filter jobs with hash' do + let(:query) { { scope: { hello: 'pending', world: 'running' } } } + + it { expect(response).to have_gitlab_http_status(:bad_request) } + end + + context 'filter jobs with array of scope elements' do + let(:query) { { scope: %w(pending running) } } + + it do + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Array + end + end + + context 'respond 400 when scope contains invalid state' do + let(:query) { { scope: %w(unknown running) } } + + it { expect(response).to have_gitlab_http_status(:bad_request) } + end + + context 'jobs in different pipelines' do + let!(:pipeline2) { create(:ci_empty_pipeline, project: project) } + let!(:job2) { create(:ci_build, pipeline: pipeline2) } + + it 'excludes jobs from other pipelines' do + json_response.each { |job| expect(job['pipeline']['id']).to eq(pipeline.id) } + end + end + + it 'avoids N+1 queries' do + control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do + get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query + end.count + + create_list(:ci_build, 3, :trace_artifact, :artifacts, :test_reports, pipeline: pipeline) + + expect do + get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query + end.not_to exceed_all_query_limit(control_count) + end + end + + context 'no pipeline is found' do + it 'does not return jobs' do + get api("/projects/#{project2.id}/pipelines/#{pipeline.id}/jobs", user) + + expect(json_response['message']).to eq '404 Project Not Found' + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'unauthorized user' do + context 'when user is not logged in' do + let(:api_user) { nil } + + it 'does not return jobs' do + expect(json_response['message']).to eq '404 Project Not Found' + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when user is guest' do + let(:guest) { create(:project_member, :guest, project: project).user } + let(:api_user) { guest } + + it 'does not return jobs' do + expect(response).to have_gitlab_http_status(:forbidden) + end + end + end + end + + context 'with ci_jobs_finder ff disabled' do + before do + stub_feature_flags(ci_jobs_finder_refactor: false) + end + + context 'authorized user' do + it 'returns pipeline jobs' do + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + end + + it 'returns correct values' do + expect(json_response).not_to be_empty + expect(json_response.first['commit']['id']).to eq project.commit.id + expect(Time.parse(json_response.first['artifacts_expire_at'])).to be_like_time(job.artifacts_expire_at) + expect(json_response.first['artifacts_file']).to be_nil + expect(json_response.first['artifacts']).to be_an Array + expect(json_response.first['artifacts']).to be_empty + end + + it_behaves_like 'a job with artifacts and trace' do + let(:api_endpoint) { "/projects/#{project.id}/pipelines/#{pipeline.id}/jobs" } + end + + it 'returns pipeline data' do + json_job = json_response.first + + expect(json_job['pipeline']).not_to be_empty + expect(json_job['pipeline']['id']).to eq job.pipeline.id + expect(json_job['pipeline']['ref']).to eq job.pipeline.ref + expect(json_job['pipeline']['sha']).to eq job.pipeline.sha + expect(json_job['pipeline']['status']).to eq job.pipeline.status + end + + context 'filter jobs with one scope element' do + let(:query) { { 'scope' => 'pending' } } + + it do + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Array + end + end + + context 'filter jobs with hash' do + let(:query) { { scope: { hello: 'pending', world: 'running' } } } + + it { expect(response).to have_gitlab_http_status(:bad_request) } + end + + context 'filter jobs with array of scope elements' do + let(:query) { { scope: %w(pending running) } } + + it do + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Array + end + end + + context 'respond 400 when scope contains invalid state' do + let(:query) { { scope: %w(unknown running) } } + + it { expect(response).to have_gitlab_http_status(:bad_request) } + end + + context 'jobs in different pipelines' do + let!(:pipeline2) { create(:ci_empty_pipeline, project: project) } + let!(:job2) { create(:ci_build, pipeline: pipeline2) } + + it 'excludes jobs from other pipelines' do + json_response.each { |job| expect(job['pipeline']['id']).to eq(pipeline.id) } + end + end + + it 'avoids N+1 queries' do + control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do + get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query + end.count + + create_list(:ci_build, 3, :trace_artifact, :artifacts, :test_reports, pipeline: pipeline) + + expect do + get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query + end.not_to exceed_all_query_limit(control_count) + end + end + + context 'no pipeline is found' do + it 'does not return jobs' do + get api("/projects/#{project2.id}/pipelines/#{pipeline.id}/jobs", user) + + expect(json_response['message']).to eq '404 Project Not Found' + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'unauthorized user' do + context 'when user is not logged in' do + let(:api_user) { nil } + + it 'does not return jobs' do + expect(json_response['message']).to eq '404 Project Not Found' + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when user is guest' do + let(:guest) { create(:project_member, :guest, project: project).user } + let(:api_user) { guest } + + it 'does not return jobs' do + expect(response).to have_gitlab_http_status(:forbidden) + end + end + end + end + end + + describe 'GET /projects/:id/pipelines/:pipeline_id/bridges' do + let_it_be(:bridge) { create(:ci_bridge, pipeline: pipeline) } + let(:downstream_pipeline) { create(:ci_pipeline) } + + let!(:pipeline_source) do + create(:ci_sources_pipeline, + source_pipeline: pipeline, + source_project: project, + source_job: bridge, + pipeline: downstream_pipeline, + project: downstream_pipeline.project) + end + + let(:query) { {} } + let(:api_user) { user } + + before do |example| + unless example.metadata[:skip_before_request] + project.update!(public_builds: false) + get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query + end + end + + context 'with ci_jobs_finder_refactor ff enabled' do + before do + stub_feature_flags(ci_jobs_finder_refactor: true) + end + + context 'authorized user' do + it 'returns pipeline bridges' do + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + end + + it 'returns correct values' do + expect(json_response).not_to be_empty + expect(json_response.first['commit']['id']).to eq project.commit.id + expect(json_response.first['id']).to eq bridge.id + expect(json_response.first['name']).to eq bridge.name + expect(json_response.first['stage']).to eq bridge.stage + end + + it 'returns pipeline data' do + json_bridge = json_response.first + + expect(json_bridge['pipeline']).not_to be_empty + expect(json_bridge['pipeline']['id']).to eq bridge.pipeline.id + expect(json_bridge['pipeline']['ref']).to eq bridge.pipeline.ref + expect(json_bridge['pipeline']['sha']).to eq bridge.pipeline.sha + expect(json_bridge['pipeline']['status']).to eq bridge.pipeline.status + end + + it 'returns downstream pipeline data' do + json_bridge = json_response.first + + expect(json_bridge['downstream_pipeline']).not_to be_empty + expect(json_bridge['downstream_pipeline']['id']).to eq downstream_pipeline.id + expect(json_bridge['downstream_pipeline']['ref']).to eq downstream_pipeline.ref + expect(json_bridge['downstream_pipeline']['sha']).to eq downstream_pipeline.sha + expect(json_bridge['downstream_pipeline']['status']).to eq downstream_pipeline.status + end + + context 'filter bridges' do + before_all do + create_bridge(pipeline, :pending) + create_bridge(pipeline, :running) + end + + context 'with one scope element' do + let(:query) { { 'scope' => 'pending' } } + + it :skip_before_request do + get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Array + expect(json_response.count).to eq 1 + expect(json_response.first["status"]).to eq "pending" + end + end + + context 'with array of scope elements' do + let(:query) { { scope: %w(pending running) } } + + it :skip_before_request do + get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Array + expect(json_response.count).to eq 2 + json_response.each { |r| expect(%w(pending running).include?(r['status'])).to be true } + end + end + end + + context 'respond 400 when scope contains invalid state' do + context 'in an array' do + let(:query) { { scope: %w(unknown running) } } + + it { expect(response).to have_gitlab_http_status(:bad_request) } + end + + context 'in a hash' do + let(:query) { { scope: { unknown: true } } } + + it { expect(response).to have_gitlab_http_status(:bad_request) } + end + + context 'in a string' do + let(:query) { { scope: "unknown" } } + + it { expect(response).to have_gitlab_http_status(:bad_request) } + end + end + + context 'bridges in different pipelines' do + let!(:pipeline2) { create(:ci_empty_pipeline, project: project) } + let!(:bridge2) { create(:ci_bridge, pipeline: pipeline2) } + + it 'excludes bridges from other pipelines' do + json_response.each { |bridge| expect(bridge['pipeline']['id']).to eq(pipeline.id) } + end + end + + it 'avoids N+1 queries' do + control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do + get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query + end.count + + 3.times { create_bridge(pipeline) } + + expect do + get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query + end.not_to exceed_all_query_limit(control_count) + end + end + + context 'no pipeline is found' do + it 'does not return bridges' do + get api("/projects/#{project2.id}/pipelines/#{pipeline.id}/bridges", user) + + expect(json_response['message']).to eq '404 Project Not Found' + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'unauthorized user' do + context 'when user is not logged in' do + let(:api_user) { nil } + + it 'does not return bridges' do + expect(json_response['message']).to eq '404 Project Not Found' + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when user is guest' do + let(:api_user) { guest } + let(:guest) { create(:project_member, :guest, project: project).user } + + it 'does not return bridges' do + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'when user has no read_build access for project' do + before do + project.add_guest(api_user) + end + + it 'does not return bridges' do + get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user) + expect(response).to have_gitlab_http_status(:forbidden) + end + end + end + end + + context 'with ci_jobs_finder_refactor ff disabled' do + before do + stub_feature_flags(ci_jobs_finder_refactor: false) + end + + context 'authorized user' do + it 'returns pipeline bridges' do + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + end + + it 'returns correct values' do + expect(json_response).not_to be_empty + expect(json_response.first['commit']['id']).to eq project.commit.id + expect(json_response.first['id']).to eq bridge.id + expect(json_response.first['name']).to eq bridge.name + expect(json_response.first['stage']).to eq bridge.stage + end + + it 'returns pipeline data' do + json_bridge = json_response.first + + expect(json_bridge['pipeline']).not_to be_empty + expect(json_bridge['pipeline']['id']).to eq bridge.pipeline.id + expect(json_bridge['pipeline']['ref']).to eq bridge.pipeline.ref + expect(json_bridge['pipeline']['sha']).to eq bridge.pipeline.sha + expect(json_bridge['pipeline']['status']).to eq bridge.pipeline.status + end + + it 'returns downstream pipeline data' do + json_bridge = json_response.first + + expect(json_bridge['downstream_pipeline']).not_to be_empty + expect(json_bridge['downstream_pipeline']['id']).to eq downstream_pipeline.id + expect(json_bridge['downstream_pipeline']['ref']).to eq downstream_pipeline.ref + expect(json_bridge['downstream_pipeline']['sha']).to eq downstream_pipeline.sha + expect(json_bridge['downstream_pipeline']['status']).to eq downstream_pipeline.status + end + + context 'filter bridges' do + before_all do + create_bridge(pipeline, :pending) + create_bridge(pipeline, :running) + end + + context 'with one scope element' do + let(:query) { { 'scope' => 'pending' } } + + it :skip_before_request do + get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Array + expect(json_response.count).to eq 1 + expect(json_response.first["status"]).to eq "pending" + end + end + + context 'with array of scope elements' do + let(:query) { { scope: %w(pending running) } } + + it :skip_before_request do + get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Array + expect(json_response.count).to eq 2 + json_response.each { |r| expect(%w(pending running).include?(r['status'])).to be true } + end + end + end + + context 'respond 400 when scope contains invalid state' do + context 'in an array' do + let(:query) { { scope: %w(unknown running) } } + + it { expect(response).to have_gitlab_http_status(:bad_request) } + end + + context 'in a hash' do + let(:query) { { scope: { unknown: true } } } + + it { expect(response).to have_gitlab_http_status(:bad_request) } + end + + context 'in a string' do + let(:query) { { scope: "unknown" } } + + it { expect(response).to have_gitlab_http_status(:bad_request) } + end + end + + context 'bridges in different pipelines' do + let!(:pipeline2) { create(:ci_empty_pipeline, project: project) } + let!(:bridge2) { create(:ci_bridge, pipeline: pipeline2) } + + it 'excludes bridges from other pipelines' do + json_response.each { |bridge| expect(bridge['pipeline']['id']).to eq(pipeline.id) } + end + end + + it 'avoids N+1 queries' do + control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do + get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query + end.count + + 3.times { create_bridge(pipeline) } + + expect do + get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query + end.not_to exceed_all_query_limit(control_count) + end + end + + context 'no pipeline is found' do + it 'does not return bridges' do + get api("/projects/#{project2.id}/pipelines/#{pipeline.id}/bridges", user) + + expect(json_response['message']).to eq '404 Project Not Found' + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'unauthorized user' do + context 'when user is not logged in' do + let(:api_user) { nil } + + it 'does not return bridges' do + expect(json_response['message']).to eq '404 Project Not Found' + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when user is guest' do + let(:api_user) { guest } + let(:guest) { create(:project_member, :guest, project: project).user } + + it 'does not return bridges' do + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'when user has no read_build access for project' do + before do + project.add_guest(api_user) + end + + it 'does not return bridges' do + get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user) + expect(response).to have_gitlab_http_status(:forbidden) + end + end + end + end + + def create_bridge(pipeline, status = :created) + create(:ci_bridge, status: status, pipeline: pipeline).tap do |bridge| + downstream_pipeline = create(:ci_pipeline) + create(:ci_sources_pipeline, + source_pipeline: pipeline, + source_project: pipeline.project, + source_job: bridge, + pipeline: downstream_pipeline, + project: downstream_pipeline.project) + end + end + end + describe 'POST /projects/:id/pipeline ' do def expect_variables(variables, expected_variables) variables.each_with_index do |variable, index| diff --git a/spec/requests/api/graphql/mutations/boards/destroy_spec.rb b/spec/requests/api/graphql/mutations/boards/destroy_spec.rb new file mode 100644 index 00000000000..a6d894e698d --- /dev/null +++ b/spec/requests/api/graphql/mutations/boards/destroy_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Boards::Destroy do + include GraphqlHelpers + + let_it_be(:current_user, reload: true) { create(:user) } + let_it_be(:project, reload: true) { create(:project) } + let_it_be(:board) { create(:board, project: project) } + let_it_be(:other_board) { create(:board, project: project) } + let(:mutation) do + variables = { + id: GitlabSchema.id_from_object(board).to_s + } + + graphql_mutation(:destroy_board, variables) + end + + subject { post_graphql_mutation(mutation, current_user: current_user) } + + def mutation_response + graphql_mutation_response(:destroy_board) + end + + context 'when the user does not have permission' do + it_behaves_like 'a mutation that returns a top-level access error' + + it 'does not destroy the board' do + expect { subject }.not_to change { Board.count } + end + end + + context 'when the user has permission' do + before do + project.add_maintainer(current_user) + end + + context 'when given id is not for a board' do + let_it_be(:board) { build_stubbed(:issue, project: project) } + + it 'returns an error' do + subject + + expect(graphql_errors.first['message']).to include('does not represent an instance of Board') + end + end + + context 'when everything is ok' do + it 'destroys the board' do + expect { subject }.to change { Board.count }.from(2).to(1) + end + + it 'returns an empty board' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(mutation_response).to have_key('board') + expect(mutation_response['board']).to be_nil + end + end + + context 'when there is only 1 board for the parent' do + before do + other_board.destroy! + end + + it 'does not destroy the board' do + expect { subject }.not_to change { Board.count }.from(1) + end + + it 'returns an error and not nil board' do + subject + + expect(mutation_response['errors']).not_to be_empty + expect(mutation_response['board']).not_to be_nil + end + end + end +end diff --git a/spec/requests/api/jobs_spec.rb b/spec/requests/api/jobs_spec.rb index b63a155a729..2d57146fbc9 100644 --- a/spec/requests/api/jobs_spec.rb +++ b/spec/requests/api/jobs_spec.rb @@ -5,32 +5,6 @@ require 'spec_helper' RSpec.describe API::Jobs do include HttpIOHelpers - shared_examples 'a job with artifacts and trace' do |result_is_array: true| - context 'with artifacts and trace' do - let!(:second_job) { create(:ci_build, :trace_artifact, :artifacts, :test_reports, pipeline: pipeline) } - - it 'returns artifacts and trace data', :skip_before_request do - get api(api_endpoint, api_user) - json_job = result_is_array ? json_response.select { |job| job['id'] == second_job.id }.first : json_response - - expect(json_job['artifacts_file']).not_to be_nil - expect(json_job['artifacts_file']).not_to be_empty - expect(json_job['artifacts_file']['filename']).to eq(second_job.artifacts_file.filename) - expect(json_job['artifacts_file']['size']).to eq(second_job.artifacts_file.size) - expect(json_job['artifacts']).not_to be_nil - expect(json_job['artifacts']).to be_an Array - expect(json_job['artifacts'].size).to eq(second_job.job_artifacts.length) - json_job['artifacts'].each do |artifact| - expect(artifact).not_to be_nil - file_type = Ci::JobArtifact.file_types[artifact['file_type']] - expect(artifact['size']).to eq(second_job.job_artifacts.find_by(file_type: file_type).size) - expect(artifact['filename']).to eq(second_job.job_artifacts.find_by(file_type: file_type).filename) - expect(artifact['file_format']).to eq(second_job.job_artifacts.find_by(file_type: file_type).file_format) - end - end - end - end - let_it_be(:project, reload: true) do create(:project, :repository, public_builds: false) end @@ -166,295 +140,6 @@ RSpec.describe API::Jobs do end end - describe 'GET /projects/:id/pipelines/:pipeline_id/jobs' do - let(:query) { {} } - - before do |example| - unless example.metadata[:skip_before_request] - job - get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query - end - end - - context 'authorized user' do - it 'returns pipeline jobs' do - expect(response).to have_gitlab_http_status(:ok) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - end - - it 'returns correct values' do - expect(json_response).not_to be_empty - expect(json_response.first['commit']['id']).to eq project.commit.id - expect(Time.parse(json_response.first['artifacts_expire_at'])).to be_like_time(job.artifacts_expire_at) - expect(json_response.first['artifacts_file']).to be_nil - expect(json_response.first['artifacts']).to be_an Array - expect(json_response.first['artifacts']).to be_empty - end - - it_behaves_like 'a job with artifacts and trace' do - let(:api_endpoint) { "/projects/#{project.id}/pipelines/#{pipeline.id}/jobs" } - end - - it 'returns pipeline data' do - json_job = json_response.first - - expect(json_job['pipeline']).not_to be_empty - expect(json_job['pipeline']['id']).to eq job.pipeline.id - expect(json_job['pipeline']['ref']).to eq job.pipeline.ref - expect(json_job['pipeline']['sha']).to eq job.pipeline.sha - expect(json_job['pipeline']['status']).to eq job.pipeline.status - end - - context 'filter jobs with one scope element' do - let(:query) { { 'scope' => 'pending' } } - - it do - expect(response).to have_gitlab_http_status(:ok) - expect(json_response).to be_an Array - end - end - - context 'filter jobs with array of scope elements' do - let(:query) { { scope: %w(pending running) } } - - it do - expect(response).to have_gitlab_http_status(:ok) - expect(json_response).to be_an Array - end - end - - context 'respond 400 when scope contains invalid state' do - let(:query) { { scope: %w(unknown running) } } - - it { expect(response).to have_gitlab_http_status(:bad_request) } - end - - context 'jobs in different pipelines' do - let!(:pipeline2) { create(:ci_empty_pipeline, project: project) } - let!(:job2) { create(:ci_build, pipeline: pipeline2) } - - it 'excludes jobs from other pipelines' do - json_response.each { |job| expect(job['pipeline']['id']).to eq(pipeline.id) } - end - end - - context 'when jobs belong to a dangling pipeline' do - let(:dangling_source) { Enums::Ci::Pipeline.dangling_sources.each_value.first } - let(:pipeline) do - create(:ci_pipeline, source: dangling_source, project: project) - end - - it 'returns pipeline jobs' do - expect(response).to have_gitlab_http_status(:ok) - expect(json_response[0]['pipeline']['sha']).to eq(pipeline.sha.to_s) - end - end - - it 'avoids N+1 queries' do - control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do - get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query - end.count - - create_list(:ci_build, 3, :trace_artifact, :artifacts, :test_reports, pipeline: pipeline) - - expect do - get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query - end.not_to exceed_all_query_limit(control_count) - end - end - - context 'unauthorized user' do - context 'when user is not logged in' do - let(:api_user) { nil } - - it 'does not return jobs' do - expect(response).to have_gitlab_http_status(:unauthorized) - end - end - - context 'when user is guest' do - let(:api_user) { guest } - - it 'does not return jobs' do - expect(response).to have_gitlab_http_status(:forbidden) - end - end - end - end - - describe 'GET /projects/:id/pipelines/:pipeline_id/bridges' do - let!(:bridge) { create(:ci_bridge, pipeline: pipeline) } - let(:downstream_pipeline) { create(:ci_pipeline) } - - let!(:pipeline_source) do - create(:ci_sources_pipeline, - source_pipeline: pipeline, - source_project: project, - source_job: bridge, - pipeline: downstream_pipeline, - project: downstream_pipeline.project) - end - - let(:query) { {} } - - before do |example| - unless example.metadata[:skip_before_request] - get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query - end - end - - context 'authorized user' do - it 'returns pipeline bridges' do - expect(response).to have_gitlab_http_status(:ok) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - end - - it 'returns correct values' do - expect(json_response).not_to be_empty - expect(json_response.first['commit']['id']).to eq project.commit.id - expect(json_response.first['id']).to eq bridge.id - expect(json_response.first['name']).to eq bridge.name - expect(json_response.first['stage']).to eq bridge.stage - end - - it 'returns pipeline data' do - json_bridge = json_response.first - - expect(json_bridge['pipeline']).not_to be_empty - expect(json_bridge['pipeline']['id']).to eq bridge.pipeline.id - expect(json_bridge['pipeline']['ref']).to eq bridge.pipeline.ref - expect(json_bridge['pipeline']['sha']).to eq bridge.pipeline.sha - expect(json_bridge['pipeline']['status']).to eq bridge.pipeline.status - end - - it 'returns downstream pipeline data' do - json_bridge = json_response.first - - expect(json_bridge['downstream_pipeline']).not_to be_empty - expect(json_bridge['downstream_pipeline']['id']).to eq downstream_pipeline.id - expect(json_bridge['downstream_pipeline']['ref']).to eq downstream_pipeline.ref - expect(json_bridge['downstream_pipeline']['sha']).to eq downstream_pipeline.sha - expect(json_bridge['downstream_pipeline']['status']).to eq downstream_pipeline.status - end - - context 'filter bridges' do - before do - create_bridge(pipeline, :pending) - create_bridge(pipeline, :running) - end - - context 'with one scope element' do - let(:query) { { 'scope' => 'pending' } } - - it :skip_before_request do - get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query - - expect(response).to have_gitlab_http_status(:ok) - expect(json_response).to be_an Array - expect(json_response.count).to eq 1 - expect(json_response.first["status"]).to eq "pending" - end - end - - context 'with array of scope elements' do - let(:query) { { scope: %w(pending running) } } - - it :skip_before_request do - get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query - - expect(response).to have_gitlab_http_status(:ok) - expect(json_response).to be_an Array - expect(json_response.count).to eq 2 - json_response.each { |r| expect(%w(pending running).include?(r['status'])).to be true } - end - end - end - - context 'respond 400 when scope contains invalid state' do - let(:query) { { scope: %w(unknown running) } } - - it { expect(response).to have_gitlab_http_status(:bad_request) } - end - - context 'bridges in different pipelines' do - let!(:pipeline2) { create(:ci_empty_pipeline, project: project) } - let!(:bridge2) { create(:ci_bridge, pipeline: pipeline2) } - - it 'excludes bridges from other pipelines' do - json_response.each { |bridge| expect(bridge['pipeline']['id']).to eq(pipeline.id) } - end - end - - it 'avoids N+1 queries' do - control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do - get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query - end.count - - 3.times { create_bridge(pipeline) } - - expect do - get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query - end.not_to exceed_all_query_limit(control_count) - end - end - - context 'unauthorized user' do - context 'when user is not logged in' do - let(:api_user) { nil } - - it 'does not return bridges' do - expect(response).to have_gitlab_http_status(:unauthorized) - end - end - - context 'when user is guest' do - let(:api_user) { guest } - - it 'does not return bridges' do - expect(response).to have_gitlab_http_status(:forbidden) - end - end - - context 'when user has no read access for pipeline' do - before do - allow(Ability).to receive(:allowed?).and_call_original - allow(Ability).to receive(:allowed?).with(api_user, :read_pipeline, pipeline).and_return(false) - end - - it 'does not return bridges' do - get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user) - expect(response).to have_gitlab_http_status(:forbidden) - end - end - - context 'when user has no read_build access for project' do - before do - allow(Ability).to receive(:allowed?).and_call_original - allow(Ability).to receive(:allowed?).with(api_user, :read_build, project).and_return(false) - end - - it 'does not return bridges' do - get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user) - expect(response).to have_gitlab_http_status(:forbidden) - end - end - end - - def create_bridge(pipeline, status = :created) - create(:ci_bridge, status: status, pipeline: pipeline).tap do |bridge| - downstream_pipeline = create(:ci_pipeline) - create(:ci_sources_pipeline, - source_pipeline: pipeline, - source_project: pipeline.project, - source_job: bridge, - pipeline: downstream_pipeline, - project: downstream_pipeline.project) - end - end - end - describe 'GET /projects/:id/jobs/:job_id' do before do |example| unless example.metadata[:skip_before_request] diff --git a/spec/requests/api/usage_data_spec.rb b/spec/requests/api/usage_data_spec.rb index 6372758bdde..46dd54dcc73 100644 --- a/spec/requests/api/usage_data_spec.rb +++ b/spec/requests/api/usage_data_spec.rb @@ -10,6 +10,17 @@ RSpec.describe API::UsageData do let(:known_event) { 'g_compliance_dashboard' } let(:unknown_event) { 'unknown' } + context 'without CSRF token' do + it 'returns forbidden' do + stub_feature_flags(usage_data_api: true) + allow(Gitlab::RequestForgeryProtection).to receive(:verified?).and_return(false) + + post api(endpoint, user), params: { event: known_event } + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + context 'usage_data_api feature not enabled' do it 'returns not_found' do stub_feature_flags(usage_data_api: false) @@ -33,6 +44,7 @@ RSpec.describe API::UsageData do stub_feature_flags(usage_data_api: true) stub_feature_flags("usage_data_#{known_event}" => true) stub_application_setting(usage_ping_enabled: true) + allow(Gitlab::RequestForgeryProtection).to receive(:verified?).and_return(true) end context 'when event is missing from params' do diff --git a/spec/rubocop/cop/gitlab/rails_logger_spec.rb b/spec/rubocop/cop/gitlab/rails_logger_spec.rb index 0583079136b..70d208b31ec 100644 --- a/spec/rubocop/cop/gitlab/rails_logger_spec.rb +++ b/spec/rubocop/cop/gitlab/rails_logger_spec.rb @@ -10,22 +10,12 @@ RSpec.describe RuboCop::Cop::Gitlab::RailsLogger, type: :rubocop do subject(:cop) { described_class.new } - it 'flags the use of Rails.logger.error with a constant receiver' do - inspect_source("Rails.logger.error('some error')") + described_class::LOG_METHODS.each do |method| + it "flags the use of Rails.logger.#{method} with a constant receiver" do + inspect_source("Rails.logger.#{method}('some error')") - expect(cop.offenses.size).to eq(1) - end - - it 'flags the use of Rails.logger.info with a constant receiver' do - inspect_source("Rails.logger.info('some info')") - - expect(cop.offenses.size).to eq(1) - end - - it 'flags the use of Rails.logger.warn with a constant receiver' do - inspect_source("Rails.logger.warn('some warning')") - - expect(cop.offenses.size).to eq(1) + expect(cop.offenses.size).to eq(1) + end end it 'does not flag the use of Rails.logger with a constant that is not Rails' do @@ -39,4 +29,10 @@ RSpec.describe RuboCop::Cop::Gitlab::RailsLogger, type: :rubocop do expect(cop.offenses.size).to eq(0) end + + it 'does not flag the use of Rails.logger.level' do + inspect_source("Rails.logger.level") + + expect(cop.offenses.size).to eq(0) + end end diff --git a/spec/services/merge_requests/cleanup_refs_service_spec.rb b/spec/services/merge_requests/cleanup_refs_service_spec.rb new file mode 100644 index 00000000000..fcd41fdca0f --- /dev/null +++ b/spec/services/merge_requests/cleanup_refs_service_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe MergeRequests::CleanupRefsService do + describe '.schedule' do + let(:merge_request) { build(:merge_request) } + + it 'schedules MergeRequestCleanupRefsWorker' do + expect(MergeRequestCleanupRefsWorker) + .to receive(:perform_in) + .with(described_class::TIME_THRESHOLD, merge_request.id) + + described_class.schedule(merge_request) + end + end + + describe '#execute' do + before do + # Need to re-enable this as it's being stubbed in spec_helper for + # performance reasons but is needed to run for this test. + allow(Gitlab::Git::KeepAround).to receive(:execute).and_call_original + end + + subject(:result) { described_class.new(merge_request).execute } + + shared_examples_for 'service that cleans up merge request refs' do + it 'creates keep around ref and deletes merge request refs' do + old_ref_head = ref_head + + aggregate_failures do + expect(result[:status]).to eq(:success) + expect(kept_around?(old_ref_head)).to be_truthy + expect(ref_head).to be_nil + end + end + + context 'when keep around ref cannot be created' do + before do + allow_next_instance_of(Gitlab::Git::KeepAround) do |keep_around| + expect(keep_around).to receive(:kept_around?).and_return(false) + end + end + + it_behaves_like 'service that does not clean up merge request refs' + end + end + + shared_examples_for 'service that does not clean up merge request refs' do + it 'does not delete merge request refs' do + aggregate_failures do + expect(result[:status]).to eq(:error) + expect(ref_head).to be_present + end + end + end + + context 'when merge request is closed' do + let(:merge_request) { create(:merge_request, :closed) } + + context "when closed #{described_class::TIME_THRESHOLD.inspect} ago" do + before do + merge_request.metrics.update!(latest_closed_at: described_class::TIME_THRESHOLD.ago) + end + + it_behaves_like 'service that cleans up merge request refs' + end + + context "when closed later than #{described_class::TIME_THRESHOLD.inspect} ago" do + before do + merge_request.metrics.update!(latest_closed_at: (described_class::TIME_THRESHOLD - 1.day).ago) + end + + it_behaves_like 'service that does not clean up merge request refs' + end + end + + context 'when merge request is merged' do + let(:merge_request) { create(:merge_request, :merged) } + + context "when merged #{described_class::TIME_THRESHOLD.inspect} ago" do + before do + merge_request.metrics.update!(merged_at: described_class::TIME_THRESHOLD.ago) + end + + it_behaves_like 'service that cleans up merge request refs' + end + + context "when merged later than #{described_class::TIME_THRESHOLD.inspect} ago" do + before do + merge_request.metrics.update!(merged_at: (described_class::TIME_THRESHOLD - 1.day).ago) + end + + it_behaves_like 'service that does not clean up merge request refs' + end + end + + context 'when merge request is not closed nor merged' do + let(:merge_request) { create(:merge_request, :opened) } + + it_behaves_like 'service that does not clean up merge request refs' + end + end + + def kept_around?(commit) + Gitlab::Git::KeepAround.new(merge_request.project.repository).kept_around?(commit.id) + end + + def ref_head + merge_request.project.repository.commit(merge_request.ref_path) + end +end diff --git a/spec/services/merge_requests/close_service_spec.rb b/spec/services/merge_requests/close_service_spec.rb index e518e439a84..e7ac286f48b 100644 --- a/spec/services/merge_requests/close_service_spec.rb +++ b/spec/services/merge_requests/close_service_spec.rb @@ -99,6 +99,12 @@ RSpec.describe MergeRequests::CloseService do described_class.new(project, user).execute(merge_request) end + it 'schedules CleanupRefsService' do + expect(MergeRequests::CleanupRefsService).to receive(:schedule).with(merge_request) + + described_class.new(project, user).execute(merge_request) + end + context 'current user is not authorized to close merge request' do before do perform_enqueued_jobs do diff --git a/spec/services/merge_requests/post_merge_service_spec.rb b/spec/services/merge_requests/post_merge_service_spec.rb index 0e7a75f2f17..402f753c0af 100644 --- a/spec/services/merge_requests/post_merge_service_spec.rb +++ b/spec/services/merge_requests/post_merge_service_spec.rb @@ -72,6 +72,12 @@ RSpec.describe MergeRequests::PostMergeService do subject end + it 'schedules CleanupRefsService' do + expect(MergeRequests::CleanupRefsService).to receive(:schedule).with(merge_request) + + subject + end + context 'when the merge request has review apps' do it 'cancels all review app deployments' do pipeline = create(:ci_pipeline, diff --git a/spec/support/shared_examples/ci/jobs_shared_examples.rb b/spec/support/shared_examples/ci/jobs_shared_examples.rb new file mode 100644 index 00000000000..d952d4a98eb --- /dev/null +++ b/spec/support/shared_examples/ci/jobs_shared_examples.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'a job with artifacts and trace' do |result_is_array: true| + context 'with artifacts and trace' do + let!(:second_job) { create(:ci_build, :trace_artifact, :artifacts, :test_reports, pipeline: pipeline) } + + it 'returns artifacts and trace data', :skip_before_request do + get api(api_endpoint, api_user) + json_job = json_response.is_a?(Array) ? json_response.find { |job| job['id'] == second_job.id } : json_response + + expect(json_job['artifacts_file']).not_to be_nil + expect(json_job['artifacts_file']).not_to be_empty + expect(json_job['artifacts_file']['filename']).to eq(second_job.artifacts_file.filename) + expect(json_job['artifacts_file']['size']).to eq(second_job.artifacts_file.size) + expect(json_job['artifacts']).not_to be_nil + expect(json_job['artifacts']).to be_an Array + expect(json_job['artifacts'].size).to eq(second_job.job_artifacts.length) + json_job['artifacts'].each do |artifact| + expect(artifact).not_to be_nil + file_type = Ci::JobArtifact.file_types[artifact['file_type']] + expect(artifact['size']).to eq(second_job.job_artifacts.find_by(file_type: file_type).size) + expect(artifact['filename']).to eq(second_job.job_artifacts.find_by(file_type: file_type).filename) + expect(artifact['file_format']).to eq(second_job.job_artifacts.find_by(file_type: file_type).file_format) + end + end + end +end diff --git a/spec/workers/merge_request_cleanup_refs_worker_spec.rb b/spec/workers/merge_request_cleanup_refs_worker_spec.rb new file mode 100644 index 00000000000..88d7322536b --- /dev/null +++ b/spec/workers/merge_request_cleanup_refs_worker_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe MergeRequestCleanupRefsWorker do + describe '#perform' do + context 'when merge request exists' do + let(:merge_request) { create(:merge_request) } + let(:job_args) { merge_request.id } + + include_examples 'an idempotent worker' do + it 'calls MergeRequests::CleanupRefsService#execute' do + expect_next_instance_of(MergeRequests::CleanupRefsService, merge_request) do |svc| + expect(svc).to receive(:execute).and_call_original + end.twice + + subject + end + end + end + + context 'when merge request does not exist' do + it 'does not call MergeRequests::CleanupRefsService' do + expect(MergeRequests::CleanupRefsService).not_to receive(:new) + + perform_multiple(1) + end + end + end +end |