Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue3
-rw-r--r--app/assets/javascripts/lib/utils/recurrence.js154
-rw-r--r--app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue76
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue10
-rw-r--r--app/controllers/projects/ci/pipeline_editor_controller.rb1
-rw-r--r--app/controllers/projects/pipelines_controller.rb2
-rw-r--r--app/graphql/types/metadata/kas_type.rb18
-rw-r--r--app/graphql/types/metadata_type.rb2
-rw-r--r--app/helpers/groups_helper.rb8
-rw-r--r--app/models/ci/stage.rb7
-rw-r--r--app/models/instance_metadata.rb3
-rw-r--r--app/models/instance_metadata/kas.rb15
-rw-r--r--app/presenters/ci/stage_presenter.rb32
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml17
-rw-r--r--app/views/projects/stage/_stage.html.haml6
-rw-r--r--changelogs/unreleased/feature-adds-kas-metadata-to-graphql.yml5
-rw-r--r--changelogs/unreleased/sh-fix-nplus-one-pipelines-show.yml5
-rw-r--r--config/feature_flags/development/pipeline_editor_drawer.yml8
-rw-r--r--doc/api/graphql/reference/index.md11
-rw-r--r--doc/development/migration_style_guide.md12
-rw-r--r--lib/gitlab/database/migrations/instrumentation.rb3
-rw-r--r--lib/gitlab/kas.rb22
-rw-r--r--lib/tasks/gitlab/db.rake10
-rw-r--r--locale/gitlab.pot6
-rw-r--r--package.json2
-rw-r--r--spec/controllers/projects/pipelines_controller_spec.rb33
-rw-r--r--spec/features/groups/group_page_with_external_authorization_service_spec.rb6
-rw-r--r--spec/features/groups/navbar_spec.rb3
-rw-r--r--spec/features/groups_spec.rb6
-rw-r--r--spec/frontend/boards/components/board_card_spec.js100
-rw-r--r--spec/frontend/lib/utils/recurrence_spec.js333
-rw-r--r--spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js74
-rw-r--r--spec/frontend/pipeline_editor/pipeline_editor_home_spec.js26
-rw-r--r--spec/graphql/resolvers/metadata_resolver_spec.rb5
-rw-r--r--spec/graphql/types/metadata/kas_type_spec.rb8
-rw-r--r--spec/lib/gitlab/kas_spec.rb40
-rw-r--r--spec/models/instance_metadata/kas_spec.rb33
-rw-r--r--spec/models/instance_metadata_spec.rb3
-rw-r--r--spec/presenters/ci/stage_presenter_spec.rb49
-rw-r--r--spec/requests/api/graphql/metadata_query_spec.rb46
-rw-r--r--spec/support/shared_contexts/navbar_structure_context.rb17
-rw-r--r--spec/tasks/gitlab/db_rake_spec.rb22
-rw-r--r--spec/views/layouts/nav/sidebar/_group.html.haml_spec.rb34
-rw-r--r--yarn.lock8
45 files changed, 1162 insertions, 124 deletions
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index 94c23ab5110..b15a3879a77 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-9523fe6434ea464a6a16c895222a4b001a5c0bca
+1481a9195c200e375a177cf201058b88bebe271b
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index 3e9c663a036..2821b799cef 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -1,5 +1,5 @@
<script>
-import { mapActions, mapGetters, mapState } from 'vuex';
+import { mapActions, mapState } from 'vuex';
import BoardCardInner from './board_card_inner.vue';
export default {
@@ -31,7 +31,6 @@ export default {
},
computed: {
...mapState(['selectedBoardItems', 'activeId']),
- ...mapGetters(['isSwimlanesOn']),
isActive() {
return this.item.id === this.activeId;
},
diff --git a/app/assets/javascripts/lib/utils/recurrence.js b/app/assets/javascripts/lib/utils/recurrence.js
new file mode 100644
index 00000000000..b9afb939090
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/recurrence.js
@@ -0,0 +1,154 @@
+import { uuids } from '../../diffs/utils/uuids';
+
+/**
+ * @module recurrence
+ */
+
+const instances = {};
+
+/**
+ * Create a new unique {@link module:recurrence~RecurInstance|RecurInstance}
+ * @returns {module:recurrence.RecurInstance} The newly created {@link module:recurrence~RecurInstance|RecurInstance}
+ */
+export function create() {
+ const id = uuids()[0];
+ let handlers = {};
+ let count = 0;
+
+ /**
+ * @namespace RecurInstance
+ * @description A RecurInstance tracks the count of any occurrence as registered by calls to <code>occur</code>.
+ * <br /><br />
+ * It maintains an internal counter and a registry of handlers that can be arbitrarily assigned by a user.
+ * <br /><br />
+ * While a RecurInstance isn't specific to any particular use-case, it may be useful for:
+ * <br />
+ * <ul>
+ * <li>Tracking repeated errors across multiple - but not linked - network requests</li>
+ * <li>Tracking repeated user interactions (e.g. multiple clicks)</li>
+ * </ul>
+ * @summary A closure to track repeated occurrences of any arbitrary event.
+ * */
+ const instance = {
+ /**
+ * @type {module:uuids~UUIDv4}
+ * @description A randomly generated {@link module:uuids~UUIDv4|UUID} for this particular recurrence instance
+ * @memberof module:recurrence~RecurInstance
+ * @readonly
+ * @inner
+ */
+ get id() {
+ return id;
+ },
+ /**
+ * @type {Number}
+ * @description The number of times this particular instance of recurrence has been triggered
+ * @memberof module:recurrence~RecurInstance
+ * @readonly
+ * @inner
+ */
+ get count() {
+ return count;
+ },
+ /**
+ * @type {Object}
+ * @description The handlers assigned to this recurrence tracker
+ * @example
+ * myRecurrence.handle( 4, () => console.log( "four" ) );
+ * console.log( myRecurrence.handlers ); // {"4": () => console.log( "four" )}
+ * @memberof module:recurrence~RecurInstance
+ * @readonly
+ * @inner
+ */
+ get handlers() {
+ return handlers;
+ },
+ /**
+ * @type {Boolean}
+ * @description Delete any internal reference to the instance.
+ * <br />
+ * Keep in mind that this will only attempt to remove the <strong>internal</strong> reference.
+ * <br />
+ * If your code maintains a reference to the instance, the regular garbage collector will not free the memory.
+ * @memberof module:recurrence~RecurInstance
+ * @inner
+ */
+ free() {
+ return delete instances[id];
+ },
+ /**
+ * @description Register a handler to be called when this occurrence is seen <code>onCount</code> number of times.
+ * @param {Number} onCount - The number of times the occurrence has been seen to respond to
+ * @param {Function} behavior - A callback function to run when the occurrence has been seen <code>onCount</code> times
+ * @memberof module:recurrence~RecurInstance
+ * @inner
+ */
+ handle(onCount, behavior) {
+ if (onCount && behavior) {
+ handlers[onCount] = behavior;
+ }
+ },
+ /**
+ * @description Remove the behavior callback handler that would be run when the occurrence is seen <code>onCount</code> times
+ * @param {Number} onCount - The count identifier for which to eject the callback handler
+ * @memberof module:recurrence~RecurInstance
+ * @inner
+ */
+ eject(onCount) {
+ if (onCount) {
+ delete handlers[onCount];
+ }
+ },
+ /**
+ * @description Register that this occurrence has been seen and trigger any appropriate handlers
+ * @memberof module:recurrence~RecurInstance
+ * @inner
+ */
+ occur() {
+ count += 1;
+
+ if (typeof handlers[count] === 'function') {
+ handlers[count](count);
+ }
+ },
+ /**
+ * @description Reset this recurrence instance without destroying it entirely
+ * @param {Object} [options]
+ * @param {Boolean} [options.currentCount = true] - Whether to reset the count
+ * @param {Boolean} [options.handlersList = false] - Whether to reset the list of attached handlers back to an empty state
+ * @memberof module:recurrence~RecurInstance
+ * @inner
+ */
+ reset({ currentCount = true, handlersList = false } = {}) {
+ if (currentCount) {
+ count = 0;
+ }
+
+ if (handlersList) {
+ handlers = {};
+ }
+ },
+ };
+
+ instances[id] = instance;
+
+ return instance;
+}
+
+/**
+ * Retrieve a stored {@link module:recurrence~RecurInstance|RecurInstance} by {@link module:uuids~UUIDv4|UUID}
+ * @param {module:uuids~UUIDv4} id - The {@link module:uuids~UUIDv4|UUID} of a previously created {@link module:recurrence~RecurInstance|RecurInstance}
+ * @returns {(module:recurrence~RecurInstance|undefined)} The {@link module:recurrence~RecurInstance|RecurInstance}, or undefined if the UUID doesn't refer to a known Instance
+ */
+export function recall(id) {
+ return instances[id];
+}
+
+/**
+ * Release the memory space for a given {@link module:recurrence~RecurInstance|RecurInstance} by {@link module:uuids~UUIDv4|UUID}
+ * @param {module:uuids~UUIDv4} id - The {@link module:uuids~UUIDv4|UUID} of a previously created {@link module:recurrence~RecurInstance|RecurInstance}
+ * @returns {Boolean} Whether the reference to the stored {@link module:recurrence~RecurInstance|RecurInstance} was released
+ */
+export function free(id) {
+ return recall(id)?.free() || false;
+}
diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
new file mode 100644
index 00000000000..ef5be8abf9a
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
@@ -0,0 +1,76 @@
+<script>
+import { GlButton, GlIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ width: {
+ expanded: '482px',
+ collapsed: '58px',
+ },
+ i18n: {
+ toggleTxt: __('Collapse'),
+ },
+ components: {
+ GlButton,
+ GlIcon,
+ },
+ data() {
+ return {
+ isExpanded: false,
+ topPosition: 0,
+ };
+ },
+ computed: {
+ buttonIconName() {
+ return this.isExpanded ? 'chevron-double-lg-right' : 'chevron-double-lg-left';
+ },
+ buttonClass() {
+ return this.isExpanded ? 'gl-justify-content-end!' : '';
+ },
+ rootStyle() {
+ const { expanded, collapsed } = this.$options.width;
+ const top = this.topPosition;
+ const style = { top: `${top}px` };
+
+ return this.isExpanded ? { ...style, width: expanded } : { ...style, width: collapsed };
+ },
+ },
+ mounted() {
+ this.setTopPosition();
+ },
+ methods: {
+ setTopPosition() {
+ const navbarEl = document.querySelector('.js-navbar');
+
+ if (navbarEl) {
+ this.topPosition = navbarEl.getBoundingClientRect().bottom;
+ }
+ },
+ toggleDrawer() {
+ this.isExpanded = !this.isExpanded;
+ },
+ },
+};
+</script>
+<template>
+ <aside
+ aria-live="polite"
+ class="gl-fixed gl-right-0 gl-h-full gl-bg-gray-10 gl-transition-medium gl-border-l-solid gl-border-1 gl-border-gray-100"
+ :style="rootStyle"
+ >
+ <gl-button
+ category="tertiary"
+ class="gl-w-full gl-h-9 gl-rounded-0! gl-border-none! gl-border-b-solid! gl-border-1! gl-border-gray-100 gl-text-decoration-none! gl-outline-0! gl-display-flex"
+ :class="buttonClass"
+ :title="__('Toggle sidebar')"
+ data-testid="toggleBtn"
+ @click="toggleDrawer"
+ >
+ <span v-if="isExpanded" class="gl-text-gray-500 gl-mr-3" data-testid="collapse-text">{{
+ __('Collapse')
+ }}</span>
+ <gl-icon data-testid="toggle-icon" :name="buttonIconName" />
+ </gl-button>
+ <div v-if="isExpanded" class="gl-p-5" data-testid="drawer-content"></div>
+ </aside>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
index adba55f9f4b..dfe9c82b912 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
@@ -1,5 +1,7 @@
<script>
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import CommitSection from './components/commit/commit_section.vue';
+import PipelineEditorDrawer from './components/drawer/pipeline_editor_drawer.vue';
import PipelineEditorFileNav from './components/file_nav/pipeline_editor_file_nav.vue';
import PipelineEditorHeader from './components/header/pipeline_editor_header.vue';
import PipelineEditorTabs from './components/pipeline_editor_tabs.vue';
@@ -8,10 +10,12 @@ import { TABS_WITH_COMMIT_FORM, CREATE_TAB } from './constants';
export default {
components: {
CommitSection,
+ PipelineEditorDrawer,
PipelineEditorFileNav,
PipelineEditorHeader,
PipelineEditorTabs,
},
+ mixins: [glFeatureFlagMixin()],
props: {
ciConfigData: {
type: Object,
@@ -35,6 +39,9 @@ export default {
showCommitForm() {
return TABS_WITH_COMMIT_FORM.includes(this.currentTab);
},
+ showPipelineDrawer() {
+ return this.glFeatures.pipelineEditorDrawer;
+ },
},
methods: {
setCurrentTab(tabName) {
@@ -45,7 +52,7 @@ export default {
</script>
<template>
- <div>
+ <div class="gl-pr-9 gl-transition-medium gl-w-full">
<pipeline-editor-file-nav v-on="$listeners" />
<pipeline-editor-header
:ci-config-data="ciConfigData"
@@ -58,5 +65,6 @@ export default {
@set-current-tab="setCurrentTab"
/>
<commit-section v-if="showCommitForm" :ci-file-content="ciFileContent" v-on="$listeners" />
+ <pipeline-editor-drawer v-if="showPipelineDrawer" />
</div>
</template>
diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb
index 4136a10e124..13c22356b60 100644
--- a/app/controllers/projects/ci/pipeline_editor_controller.rb
+++ b/app/controllers/projects/ci/pipeline_editor_controller.rb
@@ -6,6 +6,7 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController
push_frontend_feature_flag(:ci_config_merged_tab, @project, default_enabled: :yaml)
push_frontend_feature_flag(:pipeline_editor_empty_state_action, @project, default_enabled: :yaml)
push_frontend_feature_flag(:pipeline_editor_branch_switcher, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:pipeline_editor_drawer, @project, default_enabled: :yaml)
end
feature_category :pipeline_authoring
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 62b464fe955..82ff7d77a6a 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -227,7 +227,7 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def render_show
- @stages = @pipeline.stages.with_latest_and_retried_statuses
+ @stages = @pipeline.stages
respond_to do |format|
format.html do
diff --git a/app/graphql/types/metadata/kas_type.rb b/app/graphql/types/metadata/kas_type.rb
new file mode 100644
index 00000000000..8af4c23270b
--- /dev/null
+++ b/app/graphql/types/metadata/kas_type.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Types
+ module Metadata
+ class KasType < ::Types::BaseObject
+ graphql_name 'Kas'
+
+ authorize :read_instance_metadata
+
+ field :enabled, GraphQL::BOOLEAN_TYPE, null: false,
+ description: 'Indicates whether the Kubernetes Agent Server is enabled.'
+ field :version, GraphQL::STRING_TYPE, null: true,
+ description: 'KAS version.'
+ field :external_url, GraphQL::STRING_TYPE, null: true,
+ description: 'The URL used by the Agents to communicate with KAS.'
+ end
+ end
+end
diff --git a/app/graphql/types/metadata_type.rb b/app/graphql/types/metadata_type.rb
index 0c360d4f292..851c2a3f1e3 100644
--- a/app/graphql/types/metadata_type.rb
+++ b/app/graphql/types/metadata_type.rb
@@ -10,5 +10,7 @@ module Types
description: 'Version.'
field :revision, GraphQL::STRING_TYPE, null: false,
description: 'Revision.'
+ field :kas, ::Types::Metadata::KasType, null: false,
+ description: 'Metadata about KAS.'
end
end
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 79d89c55f28..a78cd752223 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -38,6 +38,14 @@ module GroupsHelper
]
end
+ def group_information_title(group)
+ if Feature.enabled?(:sidebar_refactor, current_user)
+ group.subgroup? ? _('Subgroup information') : _('Group information')
+ else
+ group.subgroup? ? _('Subgroup overview') : _('Group overview')
+ end
+ end
+
def group_container_registry_nav?
Gitlab.config.registry.enabled &&
can?(current_user, :read_container_image, @group)
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index 7c5324a2181..ef920b2d589 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -6,6 +6,7 @@ module Ci
include Importable
include Ci::HasStatus
include Gitlab::OptimisticLocking
+ include Presentable
enum status: Ci::HasStatus::STATUSES_ENUM
@@ -22,12 +23,6 @@ module Ci
scope :ordered, -> { order(position: :asc) }
scope :in_pipelines, ->(pipelines) { where(pipeline: pipelines) }
scope :by_name, ->(names) { where(name: names) }
- scope :with_latest_and_retried_statuses, -> do
- includes(
- latest_statuses: [:pipeline, project: :namespace],
- retried_statuses: [:pipeline, project: :namespace]
- )
- end
with_options unless: :importing? do
validates :project, presence: true
diff --git a/app/models/instance_metadata.rb b/app/models/instance_metadata.rb
index 96622d0b1b3..6cac78178e0 100644
--- a/app/models/instance_metadata.rb
+++ b/app/models/instance_metadata.rb
@@ -1,10 +1,11 @@
# frozen_string_literal: true
class InstanceMetadata
- attr_reader :version, :revision
+ attr_reader :version, :revision, :kas
def initialize(version: Gitlab::VERSION, revision: Gitlab.revision)
@version = version
@revision = revision
+ @kas = ::InstanceMetadata::Kas.new
end
end
diff --git a/app/models/instance_metadata/kas.rb b/app/models/instance_metadata/kas.rb
new file mode 100644
index 00000000000..7d2d71120b5
--- /dev/null
+++ b/app/models/instance_metadata/kas.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class InstanceMetadata::Kas
+ attr_reader :enabled, :version, :external_url
+
+ def initialize
+ @enabled = Gitlab::Kas.enabled?
+ @version = Gitlab::Kas.version if @enabled
+ @external_url = Gitlab::Kas.external_url if @enabled
+ end
+
+ def self.declarative_policy_class
+ "InstanceMetadataPolicy"
+ end
+end
diff --git a/app/presenters/ci/stage_presenter.rb b/app/presenters/ci/stage_presenter.rb
new file mode 100644
index 00000000000..9ec3f8d153a
--- /dev/null
+++ b/app/presenters/ci/stage_presenter.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Ci
+ class StagePresenter < Gitlab::View::Presenter::Delegated
+ presents :stage
+
+ def latest_ordered_statuses
+ preload_statuses(stage.statuses.latest_ordered)
+ end
+
+ def retried_ordered_statuses
+ preload_statuses(stage.statuses.retried_ordered)
+ end
+
+ private
+
+ def preload_statuses(statuses)
+ loaded_statuses = statuses.load
+ statuses.tap do |statuses|
+ # rubocop: disable CodeReuse/ActiveRecord
+ ActiveRecord::Associations::Preloader.new.preload(preloadable_statuses(loaded_statuses), %w[pipeline tags job_artifacts_archive metadata])
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+ end
+
+ def preloadable_statuses(statuses)
+ statuses.reject do |status|
+ status.instance_of?(::GenericCommitStatus) || status.instance_of?(::Ci::Bridge)
+ end
+ end
+ end
+end
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index a89ba072c0e..286d12b9ac8 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -1,7 +1,6 @@
- issues_count = cached_issuables_count(@group, type: :issues)
- merge_requests_count = group_open_merge_requests_count(@group)
- aside_title = @group.subgroup? ? _('Subgroup navigation') : _('Group navigation')
-- overview_title = @group.subgroup? ? _('Subgroup overview') : _('Group overview')
%aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **sidebar_tracking_attributes_by_object(@group), 'aria-label': aside_title }
.nav-sidebar-inner-scroll
@@ -19,21 +18,23 @@
= nav_link(path: paths, unless: -> { current_path?('groups/contribution_analytics#show') }, html_options: { class: 'home' }) do
= link_to group_path(@group) do
.nav-icon-container
- = sprite_icon('home')
+ - sprite = Feature.enabled?(:sidebar_refactor, current_user) ? 'group' : 'home'
+ = sprite_icon(sprite)
%span.nav-item-name
- = overview_title
+ = group_information_title(@group)
%ul.sidebar-sub-level-items
= nav_link(path: ['groups#show', 'groups#details', 'groups#activity', 'groups#subgroups'], html_options: { class: "fly-out-top-item" } ) do
= link_to group_path(@group) do
%strong.fly-out-top-item-name
- = overview_title
+ = group_information_title(@group)
%li.divider.fly-out-top-item
- = nav_link(path: ['groups#show', 'groups#details', 'groups#subgroups'], html_options: { class: 'home' }) do
- = link_to details_group_path(@group), title: _('Group details') do
- %span
- = _('Details')
+ - if Feature.disabled?(:sidebar_refactor, current_user)
+ = nav_link(path: ['groups#show', 'groups#details', 'groups#subgroups'], html_options: { class: 'home' }) do
+ = link_to details_group_path(@group), title: _('Group details') do
+ %span
+ = _('Details')
- if group_sidebar_link?(:activity)
= nav_link(path: 'groups#activity') do
diff --git a/app/views/projects/stage/_stage.html.haml b/app/views/projects/stage/_stage.html.haml
index 92bfd5a48a8..387c8fb3234 100644
--- a/app/views/projects/stage/_stage.html.haml
+++ b/app/views/projects/stage/_stage.html.haml
@@ -1,3 +1,5 @@
+- stage = stage.present(current_user: current_user)
+
%tr
%th{ colspan: 10 }
%strong
@@ -6,8 +8,8 @@
= ci_icon_for_status(stage.status)
&nbsp;
= stage.name.titleize
-= render stage.latest_statuses, stage: false, ref: false, pipeline_link: false, allow_retry: true
-= render stage.retried_statuses, stage: false, ref: false, pipeline_link: false, retried: true
+= render stage.latest_ordered_statuses, stage: false, ref: false, pipeline_link: false, allow_retry: true
+= render stage.retried_ordered_statuses, stage: false, ref: false, pipeline_link: false, retried: true
%tr
%td{ colspan: 10 }
&nbsp;
diff --git a/changelogs/unreleased/feature-adds-kas-metadata-to-graphql.yml b/changelogs/unreleased/feature-adds-kas-metadata-to-graphql.yml
new file mode 100644
index 00000000000..dd4204308a9
--- /dev/null
+++ b/changelogs/unreleased/feature-adds-kas-metadata-to-graphql.yml
@@ -0,0 +1,5 @@
+---
+title: Expose KAS metadata through GraphQL - enabled, version and externalUrl
+merge_request: 59696
+author:
+type: added
diff --git a/changelogs/unreleased/sh-fix-nplus-one-pipelines-show.yml b/changelogs/unreleased/sh-fix-nplus-one-pipelines-show.yml
new file mode 100644
index 00000000000..ebaf2aee123
--- /dev/null
+++ b/changelogs/unreleased/sh-fix-nplus-one-pipelines-show.yml
@@ -0,0 +1,5 @@
+---
+title: Fix N+1 SQL queries in PipelinesController#show
+merge_request: 60794
+author:
+type: fixed
diff --git a/config/feature_flags/development/pipeline_editor_drawer.yml b/config/feature_flags/development/pipeline_editor_drawer.yml
new file mode 100644
index 00000000000..354161b0ae8
--- /dev/null
+++ b/config/feature_flags/development/pipeline_editor_drawer.yml
@@ -0,0 +1,8 @@
+---
+name: pipeline_editor_drawer
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/60856
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/329806
+milestone: '13.12'
+type: development
+group: group::pipeline authoring
+default_enabled: false
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 8b2c264b49d..6651da9267c 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -9511,6 +9511,16 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="jobpermissionsreadjobartifacts"></a>`readJobArtifacts` | [`Boolean!`](#boolean) | Indicates the user can perform `read_job_artifacts` on this resource. |
| <a id="jobpermissionsupdatebuild"></a>`updateBuild` | [`Boolean!`](#boolean) | Indicates the user can perform `update_build` on this resource. |
+### `Kas`
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="kasenabled"></a>`enabled` | [`Boolean!`](#boolean) | Indicates whether the Kubernetes Agent Server is enabled. |
+| <a id="kasexternalurl"></a>`externalUrl` | [`String`](#string) | The URL used by the Agents to communicate with KAS. |
+| <a id="kasversion"></a>`version` | [`String`](#string) | KAS version. |
+
### `Label`
#### Fields
@@ -10110,6 +10120,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
| Name | Type | Description |
| ---- | ---- | ----------- |
+| <a id="metadatakas"></a>`kas` | [`Kas!`](#kas) | Metadata about KAS. |
| <a id="metadatarevision"></a>`revision` | [`String!`](#string) | Revision. |
| <a id="metadataversion"></a>`version` | [`String!`](#string) | Version. |
diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md
index 9aefbed4f07..e1444f1a726 100644
--- a/doc/development/migration_style_guide.md
+++ b/doc/development/migration_style_guide.md
@@ -126,6 +126,18 @@ Examples:
- `index_projects_on_id_service_desk_enabled`
- `index_clusters_on_enabled_cluster_type_id_and_created_at`
+### Truncate long index names
+
+PostgreSQL [limits the length of identifiers](https://www.postgresql.org/docs/current/limits.html),
+like column or index names. Column names are not usually a problem, but index names tend
+to be longer. Some methods for shortening a name that's too long:
+
+- Prefix it with `i_` instead of `index_`.
+- Skip redundant prefixes. For example,
+ `index_vulnerability_findings_remediations_on_vulnerability_remediation_id` becomes
+ `index_vulnerability_findings_remediations_on_remediation_id`.
+- Instead of columns, specify the purpose of the index, such as `index_users_for_unconfirmation_notification`.
+
## Heavy operations in a single transaction
When using a single-transaction migration, a transaction holds a database connection
diff --git a/lib/gitlab/database/migrations/instrumentation.rb b/lib/gitlab/database/migrations/instrumentation.rb
index 9cc1196946e..e9ef80d5198 100644
--- a/lib/gitlab/database/migrations/instrumentation.rb
+++ b/lib/gitlab/database/migrations/instrumentation.rb
@@ -4,6 +4,9 @@ module Gitlab
module Database
module Migrations
class Instrumentation
+ RESULT_DIR = Rails.root.join('tmp', 'migration-testing').freeze
+ STATS_FILENAME = 'migration-stats.json'
+
attr_reader :observations
def initialize(observers = ::Gitlab::Database::Migrations::Observers.all_observers)
diff --git a/lib/gitlab/kas.rb b/lib/gitlab/kas.rb
index 7a674cb5c21..7b2c792ebca 100644
--- a/lib/gitlab/kas.rb
+++ b/lib/gitlab/kas.rb
@@ -3,6 +3,7 @@
module Gitlab
module Kas
INTERNAL_API_REQUEST_HEADER = 'Gitlab-Kas-Api-Request'
+ VERSION_FILE = 'GITLAB_KAS_VERSION'
JWT_ISSUER = 'gitlab-kas'
include JwtAuthenticatable
@@ -29,6 +30,27 @@ module Gitlab
Feature.enabled?(:kubernetes_agent_on_gitlab_com, project, default_enabled: :yaml)
end
+
+ # Return GitLab KAS version
+ #
+ # @return [String] version
+ def version
+ @_version ||= Rails.root.join(VERSION_FILE).read.chomp
+ end
+
+ # Return GitLab KAS external_url
+ #
+ # @return [String] external_url
+ def external_url
+ Gitlab.config.gitlab_kas.external_url
+ end
+
+ # Return whether GitLab KAS is enabled
+ #
+ # @return [Boolean] external_url
+ def enabled?
+ !!Gitlab.config['gitlab_kas']&.fetch('enabled', false)
+ end
end
end
end
diff --git a/lib/tasks/gitlab/db.rake b/lib/tasks/gitlab/db.rake
index 939053697c5..bbfdf598e42 100644
--- a/lib/tasks/gitlab/db.rake
+++ b/lib/tasks/gitlab/db.rake
@@ -217,9 +217,11 @@ namespace :gitlab do
end
desc 'Run migrations with instrumentation'
- task :migration_testing, [:result_file] => :environment do |_, args|
- result_file = args[:result_file] || raise("Please specify result_file argument")
- raise "File exists already, won't overwrite: #{result_file}" if File.exist?(result_file)
+ task migration_testing: :environment do
+ result_dir = Gitlab::Database::Migrations::Instrumentation::RESULT_DIR
+ raise "Directory exists already, won't overwrite: #{result_dir}" if File.exist?(result_dir)
+
+ Dir.mkdir(result_dir)
verbose_was = ActiveRecord::Migration.verbose
ActiveRecord::Migration.verbose = true
@@ -240,7 +242,7 @@ namespace :gitlab do
end
ensure
if instrumentation
- File.open(result_file, 'wb+') do |io|
+ File.open(File.join(result_dir, Gitlab::Database::Migrations::Instrumentation::STATS_FILENAME), 'wb+') do |io|
io << instrumentation.observations.to_json
end
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 16e13d780e0..2097a1b5661 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -15477,6 +15477,9 @@ msgstr ""
msgid "Group info:"
msgstr ""
+msgid "Group information"
+msgstr ""
+
msgid "Group is required when cluster_type is :group"
msgstr ""
@@ -30725,6 +30728,9 @@ msgstr ""
msgid "StorageSize|Unknown"
msgstr ""
+msgid "Subgroup information"
+msgstr ""
+
msgid "Subgroup milestone"
msgstr ""
diff --git a/package.json b/package.json
index 7e127bd77c4..47ca621611a 100644
--- a/package.json
+++ b/package.json
@@ -53,7 +53,7 @@
"@gitlab/favicon-overlay": "2.0.0",
"@gitlab/svgs": "1.192.0",
"@gitlab/tributejs": "1.0.0",
- "@gitlab/ui": "29.13.0",
+ "@gitlab/ui": "29.14.0",
"@gitlab/visual-review-tools": "1.6.1",
"@rails/actioncable": "^6.0.3-4",
"@rails/ujs": "^6.0.3-4",
diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb
index 6236a47cde1..2cbc85232b4 100644
--- a/spec/controllers/projects/pipelines_controller_spec.rb
+++ b/spec/controllers/projects/pipelines_controller_spec.rb
@@ -290,6 +290,39 @@ RSpec.describe Projects::PipelinesController do
end
end
+ describe 'GET #show' do
+ render_views
+
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
+
+ subject { get_pipeline_html }
+
+ def get_pipeline_html
+ get :show, params: { namespace_id: project.namespace, project_id: project, id: pipeline }, format: :html
+ end
+
+ def create_build_with_artifacts(stage, stage_idx, name)
+ create(:ci_build, :artifacts, :tags, pipeline: pipeline, stage: stage, stage_idx: stage_idx, name: name)
+ end
+
+ before do
+ create_build_with_artifacts('build', 0, 'job1')
+ create_build_with_artifacts('build', 0, 'job2')
+ end
+
+ it 'avoids N+1 database queries', :request_store do
+ get_pipeline_html
+
+ control_count = ActiveRecord::QueryRecorder.new { get_pipeline_html }.count
+ expect(response).to have_gitlab_http_status(:ok)
+
+ create_build_with_artifacts('build', 0, 'job3')
+
+ expect { get_pipeline_html }.not_to exceed_query_limit(control_count)
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
describe 'GET show.json' do
let(:pipeline) { create(:ci_pipeline, project: project) }
diff --git a/spec/features/groups/group_page_with_external_authorization_service_spec.rb b/spec/features/groups/group_page_with_external_authorization_service_spec.rb
index 187d878472e..59a7feb813b 100644
--- a/spec/features/groups/group_page_with_external_authorization_service_spec.rb
+++ b/spec/features/groups/group_page_with_external_authorization_service_spec.rb
@@ -15,8 +15,7 @@ RSpec.describe 'The group page' do
def expect_all_sidebar_links
within('.nav-sidebar') do
- expect(page).to have_link('Group overview')
- expect(page).to have_link('Details')
+ expect(page).to have_link('Group information')
expect(page).to have_link('Activity')
expect(page).to have_link('Issues')
expect(page).to have_link('Merge requests')
@@ -44,8 +43,7 @@ RSpec.describe 'The group page' do
visit group_path(group)
within('.nav-sidebar') do
- expect(page).to have_link('Group overview')
- expect(page).to have_link('Details')
+ expect(page).to have_link('Group information')
expect(page).not_to have_link('Activity')
expect(page).not_to have_link('Contribution')
diff --git a/spec/features/groups/navbar_spec.rb b/spec/features/groups/navbar_spec.rb
index 021b1af54d4..7f0aef6b300 100644
--- a/spec/features/groups/navbar_spec.rb
+++ b/spec/features/groups/navbar_spec.rb
@@ -14,9 +14,8 @@ RSpec.describe 'Group navbar' do
let(:structure) do
[
{
- nav_item: _('Group overview'),
+ nav_item: _('Group information'),
nav_sub_items: [
- _('Details'),
_('Activity')
]
},
diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb
index a43946925bf..0fab5718aa6 100644
--- a/spec/features/groups_spec.rb
+++ b/spec/features/groups_spec.rb
@@ -368,14 +368,14 @@ RSpec.describe 'Group' do
expect(page).to have_content(nested_group.name)
expect(page).to have_content(project.name)
- expect(page).to have_link('Group overview')
+ expect(page).to have_link('Group information')
end
- it 'renders subgroup page with the text "Subgroup overview"' do
+ it 'renders subgroup page with the text "Subgroup information"' do
visit group_path(nested_group)
wait_for_requests
- expect(page).to have_link('Subgroup overview')
+ expect(page).to have_link('Subgroup information')
end
it 'renders project page with the text "Project overview"' do
diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js
index e95cb17ee84..ceafa6ead94 100644
--- a/spec/frontend/boards/components/board_card_spec.js
+++ b/spec/frontend/boards/components/board_card_spec.js
@@ -15,7 +15,7 @@ describe('Board card', () => {
const localVue = createLocalVue();
localVue.use(Vuex);
- const createStore = ({ initialState = {}, isSwimlanesOn = false } = {}) => {
+ const createStore = ({ initialState = {} } = {}) => {
mockActions = {
toggleBoardItem: jest.fn(),
toggleBoardItemMultiSelection: jest.fn(),
@@ -30,7 +30,6 @@ describe('Board card', () => {
},
actions: mockActions,
getters: {
- isSwimlanesOn: () => isSwimlanesOn,
isEpicBoard: () => false,
},
});
@@ -90,72 +89,65 @@ describe('Board card', () => {
});
});
- describe.each`
- isSwimlanesOn
- ${true} | ${false}
- `('when isSwimlanesOn is $isSwimlanesOn', ({ isSwimlanesOn }) => {
- it('should not highlight the card by default', async () => {
- createStore({ isSwimlanesOn });
- mountComponent();
+ it('should not highlight the card by default', async () => {
+ createStore();
+ mountComponent();
+
+ expect(wrapper.classes()).not.toContain('is-active');
+ expect(wrapper.classes()).not.toContain('multi-select');
+ });
- expect(wrapper.classes()).not.toContain('is-active');
- expect(wrapper.classes()).not.toContain('multi-select');
+ it('should highlight the card with a correct style when selected', async () => {
+ createStore({
+ initialState: {
+ activeId: mockIssue.id,
+ },
});
+ mountComponent();
- it('should highlight the card with a correct style when selected', async () => {
- createStore({
- initialState: {
- activeId: mockIssue.id,
- },
- isSwimlanesOn,
- });
- mountComponent();
+ expect(wrapper.classes()).toContain('is-active');
+ expect(wrapper.classes()).not.toContain('multi-select');
+ });
- expect(wrapper.classes()).toContain('is-active');
- expect(wrapper.classes()).not.toContain('multi-select');
+ it('should highlight the card with a correct style when multi-selected', async () => {
+ createStore({
+ initialState: {
+ activeId: inactiveId,
+ selectedBoardItems: [mockIssue],
+ },
});
+ mountComponent();
- it('should highlight the card with a correct style when multi-selected', async () => {
- createStore({
- initialState: {
- activeId: inactiveId,
- selectedBoardItems: [mockIssue],
- },
- isSwimlanesOn,
- });
- mountComponent();
+ expect(wrapper.classes()).toContain('multi-select');
+ expect(wrapper.classes()).not.toContain('is-active');
+ });
- expect(wrapper.classes()).toContain('multi-select');
- expect(wrapper.classes()).not.toContain('is-active');
+ describe('when mouseup event is called on the card', () => {
+ beforeEach(() => {
+ createStore();
+ mountComponent();
});
- describe('when mouseup event is called on the card', () => {
- beforeEach(() => {
- createStore({ isSwimlanesOn });
- mountComponent();
- });
-
- describe('when not using multi-select', () => {
- it('should call vuex action "toggleBoardItem" with correct parameters', async () => {
- await selectCard();
+ describe('when not using multi-select', () => {
+ it('should call vuex action "toggleBoardItem" with correct parameters', async () => {
+ await selectCard();
- expect(mockActions.toggleBoardItem).toHaveBeenCalledTimes(1);
- expect(mockActions.toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), {
- boardItem: mockIssue,
- });
+ expect(mockActions.toggleBoardItem).toHaveBeenCalledTimes(1);
+ expect(mockActions.toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), {
+ boardItem: mockIssue,
});
});
+ });
- describe('when using multi-select', () => {
- it('should call vuex action "multiSelectBoardItem" with correct parameters', async () => {
- await multiSelectCard();
+ describe('when using multi-select', () => {
+ it('should call vuex action "multiSelectBoardItem" with correct parameters', async () => {
+ await multiSelectCard();
- expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledTimes(1);
- expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledWith(
- expect.any(Object),
- mockIssue,
- );
- });
+ expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledTimes(1);
+ expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledWith(
+ expect.any(Object),
+ mockIssue,
+ );
});
});
});
diff --git a/spec/frontend/lib/utils/recurrence_spec.js b/spec/frontend/lib/utils/recurrence_spec.js
new file mode 100644
index 00000000000..fc22529dffc
--- /dev/null
+++ b/spec/frontend/lib/utils/recurrence_spec.js
@@ -0,0 +1,333 @@
+import { create, free, recall } from '~/lib/utils/recurrence';
+
+const HEX = /[a-f0-9]/i;
+const HEX_RE = HEX.source;
+const UUIDV4 = new RegExp(
+ `${HEX_RE}{8}-${HEX_RE}{4}-4${HEX_RE}{3}-[89ab]${HEX_RE}{3}-${HEX_RE}{12}`,
+ 'i',
+);
+
+describe('recurrence', () => {
+ let recurInstance;
+ let id;
+
+ beforeEach(() => {
+ recurInstance = create();
+ id = recurInstance.id;
+ });
+
+ afterEach(() => {
+ id = null;
+ recurInstance.free();
+ });
+
+ describe('create', () => {
+ it('returns an object with the correct external api', () => {
+ expect(recurInstance).toMatchObject(
+ expect.objectContaining({
+ id: expect.stringMatching(UUIDV4),
+ count: 0,
+ handlers: {},
+ free: expect.any(Function),
+ handle: expect.any(Function),
+ eject: expect.any(Function),
+ occur: expect.any(Function),
+ reset: expect.any(Function),
+ }),
+ );
+ });
+ });
+
+ describe('recall', () => {
+ it('returns a previously created RecurInstance', () => {
+ expect(recall(id).id).toBe(id);
+ });
+
+ it("returns undefined if the provided UUID doesn't refer to a stored RecurInstance", () => {
+ expect(recall('1234')).toBeUndefined();
+ });
+ });
+
+ describe('free', () => {
+ it('returns true when the RecurInstance exists', () => {
+ expect(free(id)).toBe(true);
+ });
+
+ it("returns false when the ID doesn't refer to a known RecurInstance", () => {
+ expect(free('1234')).toBe(false);
+ });
+
+ it('removes the correct RecurInstance from the list of references', () => {
+ const anotherInstance = create();
+
+ expect(recall(id)).toEqual(recurInstance);
+ expect(recall(anotherInstance.id)).toEqual(anotherInstance);
+
+ free(id);
+
+ expect(recall(id)).toBeUndefined();
+ expect(recall(anotherInstance.id)).toEqual(anotherInstance);
+
+ anotherInstance.free();
+ });
+ });
+
+ describe('RecurInstance (`create()` return value)', () => {
+ it.each`
+ property | value | alias
+ ${'id'} | ${expect.stringMatching(UUIDV4)} | ${'[a string matching the UUIDv4 specification]'}
+ ${'count'} | ${0} | ${0}
+ ${'handlers'} | ${{}} | ${{}}
+ `(
+ 'has the correct primitive value $alias for the member `$property` to start',
+ ({ property, value }) => {
+ expect(recurInstance[property]).toEqual(value);
+ },
+ );
+
+ describe('id', () => {
+ it('cannot be changed manually', () => {
+ expect(() => {
+ recurInstance.id = 'new-id';
+ }).toThrow(TypeError);
+
+ expect(recurInstance.id).toBe(id);
+ });
+
+ it.each`
+ method
+ ${'free'}
+ ${'handle'}
+ ${'eject'}
+ ${'occur'}
+ ${'reset'}
+ `('does not change across any method call - like after `$method`', ({ method }) => {
+ recurInstance[method]();
+
+ expect(recurInstance.id).toBe(id);
+ });
+ });
+
+ describe('count', () => {
+ it('cannot be changed manually', () => {
+ expect(() => {
+ recurInstance.count = 9999;
+ }).toThrow(TypeError);
+
+ expect(recurInstance.count).toBe(0);
+ });
+
+ it.each`
+ method
+ ${'free'}
+ ${'handle'}
+ ${'eject'}
+ ${'reset'}
+ `("doesn't change in unexpected scenarios - like after a call to `$method`", ({ method }) => {
+ recurInstance[method]();
+
+ expect(recurInstance.count).toBe(0);
+ });
+
+ it('increments by one each time `.occur()` is called', () => {
+ expect(recurInstance.count).toBe(0);
+ recurInstance.occur();
+ expect(recurInstance.count).toBe(1);
+ recurInstance.occur();
+ expect(recurInstance.count).toBe(2);
+ });
+ });
+
+ describe('handlers', () => {
+ it('cannot be changed manually', () => {
+ const fn = jest.fn();
+
+ recurInstance.handle(1, fn);
+ expect(() => {
+ recurInstance.handlers = {};
+ }).toThrow(TypeError);
+
+ expect(recurInstance.handlers).toStrictEqual({
+ 1: fn,
+ });
+ });
+
+ it.each`
+ method
+ ${'free'}
+ ${'occur'}
+ ${'eject'}
+ ${'reset'}
+ `("doesn't change in unexpected scenarios - like after a call to `$method`", ({ method }) => {
+ recurInstance[method]();
+
+ expect(recurInstance.handlers).toEqual({});
+ });
+
+ it('adds handlers to the correct slots', () => {
+ const fn1 = jest.fn();
+ const fn2 = jest.fn();
+
+ recurInstance.handle(100, fn1);
+ recurInstance.handle(1000, fn2);
+
+ expect(recurInstance.handlers).toMatchObject({
+ 100: fn1,
+ 1000: fn2,
+ });
+ });
+ });
+
+ describe('free', () => {
+ it('removes itself from recallable memory', () => {
+ expect(recall(id)).toEqual(recurInstance);
+
+ recurInstance.free();
+
+ expect(recall(id)).toBeUndefined();
+ });
+ });
+
+ describe('handle', () => {
+ it('adds a handler for the provided count', () => {
+ const fn = jest.fn();
+
+ recurInstance.handle(5, fn);
+
+ expect(recurInstance.handlers[5]).toEqual(fn);
+ });
+
+ it("doesn't add any handlers if either the count or behavior aren't provided", () => {
+ const fn = jest.fn();
+
+ recurInstance.handle(null, fn);
+ // Note that it's not possible to react to something not happening (without timers)
+ recurInstance.handle(0, fn);
+ recurInstance.handle(5, null);
+
+ expect(recurInstance.handlers).toEqual({});
+ });
+ });
+
+ describe('eject', () => {
+ it('removes the handler assigned to the particular count slot', () => {
+ recurInstance.handle(1, jest.fn());
+
+ expect(recurInstance.handlers[1]).toBeTruthy();
+
+ recurInstance.eject(1);
+
+ expect(recurInstance.handlers).toEqual({});
+ });
+
+ it("succeeds (or fails gracefully) when the count provided doesn't have a handler assigned", () => {
+ recurInstance.eject('abc');
+ recurInstance.eject(1);
+
+ expect(recurInstance.handlers).toEqual({});
+ });
+
+ it('makes no changes if no count is provided', () => {
+ const fn = jest.fn();
+
+ recurInstance.handle(1, fn);
+
+ recurInstance.eject();
+
+ expect(recurInstance.handlers[1]).toStrictEqual(fn);
+ });
+ });
+
+ describe('occur', () => {
+ it('increments the .count property by 1', () => {
+ expect(recurInstance.count).toBe(0);
+
+ recurInstance.occur();
+
+ expect(recurInstance.count).toBe(1);
+ });
+
+ it('calls the appropriate handlers', () => {
+ const fn1 = jest.fn();
+ const fn5 = jest.fn();
+ const fn10 = jest.fn();
+
+ recurInstance.handle(1, fn1);
+ recurInstance.handle(5, fn5);
+ recurInstance.handle(10, fn10);
+
+ expect(fn1).not.toHaveBeenCalled();
+ expect(fn5).not.toHaveBeenCalled();
+ expect(fn10).not.toHaveBeenCalled();
+
+ recurInstance.occur();
+
+ expect(fn1).toHaveBeenCalledTimes(1);
+ expect(fn5).not.toHaveBeenCalled();
+ expect(fn10).not.toHaveBeenCalled();
+
+ recurInstance.occur();
+ recurInstance.occur();
+ recurInstance.occur();
+ recurInstance.occur();
+
+ expect(fn1).toHaveBeenCalledTimes(1);
+ expect(fn5).toHaveBeenCalledTimes(1);
+ expect(fn10).not.toHaveBeenCalled();
+
+ recurInstance.occur();
+ recurInstance.occur();
+ recurInstance.occur();
+ recurInstance.occur();
+ recurInstance.occur();
+
+ expect(fn1).toHaveBeenCalledTimes(1);
+ expect(fn5).toHaveBeenCalledTimes(1);
+ expect(fn10).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('reset', () => {
+ it('resets the count only, by default', () => {
+ const fn = jest.fn();
+
+ recurInstance.handle(3, fn);
+ recurInstance.occur();
+ recurInstance.occur();
+
+ expect(recurInstance.count).toBe(2);
+
+ recurInstance.reset();
+
+ expect(recurInstance.count).toBe(0);
+ expect(recurInstance.handlers).toEqual({ 3: fn });
+ });
+
+ it('also resets the handlers, by specific request', () => {
+ const fn = jest.fn();
+
+ recurInstance.handle(3, fn);
+ recurInstance.occur();
+ recurInstance.occur();
+
+ expect(recurInstance.count).toBe(2);
+
+ recurInstance.reset({ handlersList: true });
+
+ expect(recurInstance.count).toBe(0);
+ expect(recurInstance.handlers).toEqual({});
+ });
+
+ it('leaves the count in place, by request', () => {
+ recurInstance.occur();
+ recurInstance.occur();
+
+ expect(recurInstance.count).toBe(2);
+
+ recurInstance.reset({ currentCount: false });
+
+ expect(recurInstance.count).toBe(2);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js b/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js
new file mode 100644
index 00000000000..587373c99b4
--- /dev/null
+++ b/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js
@@ -0,0 +1,74 @@
+import { shallowMount } from '@vue/test-utils';
+import PipelineEditorDrawer from '~/pipeline_editor/components/drawer/pipeline_editor_drawer.vue';
+
+describe('Pipeline editor drawer', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(PipelineEditorDrawer);
+ };
+
+ const findToggleBtn = () => wrapper.find('[data-testid="toggleBtn"]');
+ const findArrowIcon = () => wrapper.find('[data-testid="toggle-icon"]');
+ const findCollapseText = () => wrapper.find('[data-testid="collapse-text"]');
+ const findDrawerContent = () => wrapper.find('[data-testid="drawer-content"]');
+
+ const clickToggleBtn = async () => findToggleBtn().vm.$emit('click');
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when the drawer is collapsed', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('show the left facing arrow icon', () => {
+ expect(findArrowIcon().props('name')).toBe('chevron-double-lg-left');
+ });
+
+ it('does not show the collapse text', () => {
+ expect(findCollapseText().exists()).toBe(false);
+ });
+
+ it('does not show the drawer content', () => {
+ expect(findDrawerContent().exists()).toBe(false);
+ });
+
+ it('can open the drawer by clicking on the toggle button', async () => {
+ expect(findDrawerContent().exists()).toBe(false);
+
+ await clickToggleBtn();
+
+ expect(findDrawerContent().exists()).toBe(true);
+ });
+ });
+
+ describe('when the drawer is expanded', () => {
+ beforeEach(async () => {
+ createComponent();
+ await clickToggleBtn();
+ });
+
+ it('show the right facing arrow icon', () => {
+ expect(findArrowIcon().props('name')).toBe('chevron-double-lg-right');
+ });
+
+ it('shows the collapse text', () => {
+ expect(findCollapseText().exists()).toBe(true);
+ });
+
+ it('show the drawer content', () => {
+ expect(findDrawerContent().exists()).toBe(true);
+ });
+
+ it('can close the drawer by clicking on the toggle button', async () => {
+ expect(findDrawerContent().exists()).toBe(true);
+
+ await clickToggleBtn();
+
+ expect(findDrawerContent().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js
index a1e3d24acfa..7aba336b8e8 100644
--- a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js
+++ b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js
@@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import CommitSection from '~/pipeline_editor/components/commit/commit_section.vue';
+import PipelineEditorDrawer from '~/pipeline_editor/components/drawer/pipeline_editor_drawer.vue';
import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue';
import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue';
import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue';
@@ -13,7 +14,7 @@ import { mockLintResponse, mockCiYml } from './mock_data';
describe('Pipeline editor home wrapper', () => {
let wrapper;
- const createComponent = ({ props = {} } = {}) => {
+ const createComponent = ({ props = {}, glFeatures = {} } = {}) => {
wrapper = shallowMount(PipelineEditorHome, {
propsData: {
ciConfigData: mockLintResponse,
@@ -22,13 +23,20 @@ describe('Pipeline editor home wrapper', () => {
isNewCiConfigFile: false,
...props,
},
+ provide: {
+ glFeatures: {
+ pipelineEditorDrawer: true,
+ ...glFeatures,
+ },
+ },
});
};
- const findPipelineEditorHeader = () => wrapper.findComponent(PipelineEditorHeader);
- const findPipelineEditorTabs = () => wrapper.findComponent(PipelineEditorTabs);
const findCommitSection = () => wrapper.findComponent(CommitSection);
const findFileNav = () => wrapper.findComponent(PipelineEditorFileNav);
+ const findPipelineEditorDrawer = () => wrapper.findComponent(PipelineEditorDrawer);
+ const findPipelineEditorHeader = () => wrapper.findComponent(PipelineEditorHeader);
+ const findPipelineEditorTabs = () => wrapper.findComponent(PipelineEditorTabs);
afterEach(() => {
wrapper.destroy();
@@ -55,6 +63,10 @@ describe('Pipeline editor home wrapper', () => {
it('shows the commit section by default', () => {
expect(findCommitSection().exists()).toBe(true);
});
+
+ it('show the pipeline drawer', () => {
+ expect(findPipelineEditorDrawer().exists()).toBe(true);
+ });
});
describe('commit form toggle', () => {
@@ -82,4 +94,12 @@ describe('Pipeline editor home wrapper', () => {
expect(findCommitSection().exists()).toBe(true);
});
});
+
+ describe('Pipeline drawer', () => {
+ it('hides the drawer when the feature flag is off', () => {
+ createComponent({ glFeatures: { pipelineEditorDrawer: false } });
+
+ expect(findPipelineEditorDrawer().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/graphql/resolvers/metadata_resolver_spec.rb b/spec/graphql/resolvers/metadata_resolver_spec.rb
index f8c01f9d531..56875e185e7 100644
--- a/spec/graphql/resolvers/metadata_resolver_spec.rb
+++ b/spec/graphql/resolvers/metadata_resolver_spec.rb
@@ -7,7 +7,10 @@ RSpec.describe Resolvers::MetadataResolver do
describe '#resolve' do
it 'returns version and revision' do
- expect(resolve(described_class)).to have_attributes(version: Gitlab::VERSION, revision: Gitlab.revision)
+ expect(resolve(described_class)).to have_attributes(
+ version: Gitlab::VERSION,
+ revision: Gitlab.revision,
+ kas: kind_of(InstanceMetadata::Kas))
end
end
end
diff --git a/spec/graphql/types/metadata/kas_type_spec.rb b/spec/graphql/types/metadata/kas_type_spec.rb
new file mode 100644
index 00000000000..f90c64f0068
--- /dev/null
+++ b/spec/graphql/types/metadata/kas_type_spec.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['Kas'] do
+ specify { expect(described_class.graphql_name).to eq('Kas') }
+ specify { expect(described_class).to require_graphql_authorizations(:read_instance_metadata) }
+end
diff --git a/spec/lib/gitlab/kas_spec.rb b/spec/lib/gitlab/kas_spec.rb
index 01ced407883..e323f76b42e 100644
--- a/spec/lib/gitlab/kas_spec.rb
+++ b/spec/lib/gitlab/kas_spec.rb
@@ -33,6 +33,46 @@ RSpec.describe Gitlab::Kas do
end
end
+ describe '.enabled?' do
+ before do
+ allow(Gitlab).to receive(:config).and_return(gitlab_config)
+ end
+
+ subject { described_class.enabled? }
+
+ context 'gitlab_config is not enabled' do
+ let(:gitlab_config) { { 'gitlab_kas' => { 'enabled' => false } } }
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'gitlab_config is enabled' do
+ let(:gitlab_config) { { 'gitlab_kas' => { 'enabled' => true } } }
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'enabled is unset' do
+ let(:gitlab_config) { { 'gitlab_kas' => {} } }
+
+ it { is_expected.to be_falsey }
+ end
+ end
+
+ describe '.external_url' do
+ it 'returns gitlab_kas external_url config' do
+ expect(described_class.external_url).to eq(Gitlab.config.gitlab_kas.external_url)
+ end
+ end
+
+ describe '.version' do
+ it 'returns gitlab_kas version config' do
+ version_file = Rails.root.join(described_class::VERSION_FILE)
+
+ expect(described_class.version).to eq(version_file.read.chomp)
+ end
+ end
+
describe '.ensure_secret!' do
context 'secret file exists' do
before do
diff --git a/spec/models/instance_metadata/kas_spec.rb b/spec/models/instance_metadata/kas_spec.rb
new file mode 100644
index 00000000000..f8cc34fa8d3
--- /dev/null
+++ b/spec/models/instance_metadata/kas_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::InstanceMetadata::Kas do
+ it 'has InstanceMetadataPolicy as declarative policy' do
+ expect(described_class.declarative_policy_class).to eq("InstanceMetadataPolicy")
+ end
+
+ context 'when KAS is enabled' do
+ it 'has the correct properties' do
+ allow(Gitlab::Kas).to receive(:enabled?).and_return(true)
+
+ expect(subject).to have_attributes(
+ enabled: Gitlab::Kas.enabled?,
+ version: Gitlab::Kas.version,
+ external_url: Gitlab::Kas.external_url
+ )
+ end
+ end
+
+ context 'when KAS is disabled' do
+ it 'has the correct properties' do
+ allow(Gitlab::Kas).to receive(:enabled?).and_return(false)
+
+ expect(subject).to have_attributes(
+ enabled: Gitlab::Kas.enabled?,
+ version: nil,
+ external_url: nil
+ )
+ end
+ end
+end
diff --git a/spec/models/instance_metadata_spec.rb b/spec/models/instance_metadata_spec.rb
index 1835dc8a9af..e3a9167620b 100644
--- a/spec/models/instance_metadata_spec.rb
+++ b/spec/models/instance_metadata_spec.rb
@@ -6,7 +6,8 @@ RSpec.describe InstanceMetadata do
it 'has the correct properties' do
expect(subject).to have_attributes(
version: Gitlab::VERSION,
- revision: Gitlab.revision
+ revision: Gitlab.revision,
+ kas: kind_of(::InstanceMetadata::Kas)
)
end
end
diff --git a/spec/presenters/ci/stage_presenter_spec.rb b/spec/presenters/ci/stage_presenter_spec.rb
new file mode 100644
index 00000000000..368f03b0150
--- /dev/null
+++ b/spec/presenters/ci/stage_presenter_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::StagePresenter do
+ let(:stage) { create(:ci_stage) }
+ let(:presenter) { described_class.new(stage) }
+
+ let!(:build) { create(:ci_build, :tags, :artifacts, pipeline: stage.pipeline, stage: stage.name) }
+ let!(:retried_build) { create(:ci_build, :tags, :artifacts, :retried, pipeline: stage.pipeline, stage: stage.name) }
+
+ before do
+ create(:generic_commit_status, pipeline: stage.pipeline, stage: stage.name)
+ end
+
+ shared_examples 'preloaded associations for CI status' do
+ it 'preloads project' do
+ expect(presented_stage.association(:project)).to be_loaded
+ end
+
+ it 'preloads build pipeline' do
+ expect(presented_stage.association(:pipeline)).to be_loaded
+ end
+
+ it 'preloads build tags' do
+ expect(presented_stage.association(:tags)).to be_loaded
+ end
+
+ it 'preloads build artifacts archive' do
+ expect(presented_stage.association(:job_artifacts_archive)).to be_loaded
+ end
+
+ it 'preloads build artifacts metadata' do
+ expect(presented_stage.association(:metadata)).to be_loaded
+ end
+ end
+
+ describe '#latest_ordered_statuses' do
+ subject(:presented_stage) { presenter.latest_ordered_statuses.second }
+
+ it_behaves_like 'preloaded associations for CI status'
+ end
+
+ describe '#retried_ordered_statuses' do
+ subject(:presented_stage) { presenter.retried_ordered_statuses.first }
+
+ it_behaves_like 'preloaded associations for CI status'
+ end
+end
diff --git a/spec/requests/api/graphql/metadata_query_spec.rb b/spec/requests/api/graphql/metadata_query_spec.rb
index 6344ec371c8..840bd7c018c 100644
--- a/spec/requests/api/graphql/metadata_query_spec.rb
+++ b/spec/requests/api/graphql/metadata_query_spec.rb
@@ -8,16 +8,48 @@ RSpec.describe 'getting project information' do
let(:query) { graphql_query_for('metadata', {}, all_graphql_fields_for('Metadata')) }
context 'logged in' do
- it 'returns version and revision' do
- post_graphql(query, current_user: create(:user))
-
- expect(graphql_errors).to be_nil
- expect(graphql_data).to eq(
+ let(:expected_data) do
+ {
'metadata' => {
'version' => Gitlab::VERSION,
- 'revision' => Gitlab.revision
+ 'revision' => Gitlab.revision,
+ 'kas' => {
+ 'enabled' => Gitlab::Kas.enabled?,
+ 'version' => expected_kas_version,
+ 'externalUrl' => expected_kas_external_url
+ }
}
- )
+ }
+ end
+
+ context 'kas is enabled' do
+ let(:expected_kas_version) { Gitlab::Kas.version }
+ let(:expected_kas_external_url) { Gitlab::Kas.external_url }
+
+ before do
+ allow(Gitlab::Kas).to receive(:enabled?).and_return(true)
+ post_graphql(query, current_user: create(:user))
+ end
+
+ it 'returns version, revision, kas_enabled, kas_version, kas_external_url' do
+ expect(graphql_errors).to be_nil
+ expect(graphql_data).to eq(expected_data)
+ end
+ end
+
+ context 'kas is disabled' do
+ let(:expected_kas_version) { nil }
+ let(:expected_kas_external_url) { nil }
+
+ before do
+ allow(Gitlab::Kas).to receive(:enabled?).and_return(false)
+ post_graphql(query, current_user: create(:user))
+ end
+
+ it 'returns version and revision' do
+ expect(graphql_errors).to be_nil
+ expect(graphql_data).to eq(expected_data)
+ end
end
end
diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb
index 51aa52a28f7..65528f3900f 100644
--- a/spec/support/shared_contexts/navbar_structure_context.rb
+++ b/spec/support/shared_contexts/navbar_structure_context.rb
@@ -170,15 +170,18 @@ RSpec.shared_context 'group navbar structure' do
}
end
+ let(:group_information_nav_item) do
+ {
+ nav_item: _('Group information'),
+ nav_sub_items: [
+ _('Activity')
+ ]
+ }
+ end
+
let(:structure) do
[
- {
- nav_item: _('Group overview'),
- nav_sub_items: [
- _('Details'),
- _('Activity')
- ]
- },
+ group_information_nav_item,
{
nav_item: _('Issues'),
nav_sub_items: [
diff --git a/spec/tasks/gitlab/db_rake_spec.rb b/spec/tasks/gitlab/db_rake_spec.rb
index 1392bae055a..c4623061944 100644
--- a/spec/tasks/gitlab/db_rake_spec.rb
+++ b/spec/tasks/gitlab/db_rake_spec.rb
@@ -298,15 +298,15 @@ RSpec.describe 'gitlab:db namespace rake task' do
end
describe '#migrate_with_instrumentation' do
- subject { run_rake_task('gitlab:db:migration_testing', "[#{filename}]") }
+ subject { run_rake_task('gitlab:db:migration_testing') }
let(:ctx) { double('ctx', migrations: all_migrations, schema_migration: double, get_all_versions: existing_versions) }
let(:instrumentation) { instance_double(Gitlab::Database::Migrations::Instrumentation, observations: observations) }
let(:existing_versions) { [1] }
let(:all_migrations) { [double('migration1', version: 1), pending_migration] }
let(:pending_migration) { double('migration2', version: 2) }
- let(:filename) { 'results-file.json'}
- let(:buffer) { StringIO.new }
+ let(:filename) { Gitlab::Database::Migrations::Instrumentation::STATS_FILENAME }
+ let!(:directory) { Dir.mktmpdir }
let(:observations) { %w[some data] }
before do
@@ -316,17 +316,19 @@ RSpec.describe 'gitlab:db namespace rake task' do
allow(instrumentation).to receive(:observe).and_yield
- allow(File).to receive(:open).with(filename, 'wb+').and_yield(buffer)
+ allow(Dir).to receive(:mkdir)
+ allow(File).to receive(:exist?).with(directory).and_return(false)
+ stub_const('Gitlab::Database::Migrations::Instrumentation::RESULT_DIR', directory)
end
- it 'fails when given no filename argument' do
- expect { run_rake_task('gitlab:db:migration_testing') }.to raise_error(/specify result_file/)
+ after do
+ FileUtils.rm_rf([directory])
end
- it 'fails when the given file already exists' do
- expect(File).to receive(:exist?).with(filename).and_return(true)
+ it 'fails when the directory already exists' do
+ expect(File).to receive(:exist?).with(directory).and_return(true)
- expect { subject }.to raise_error(/File exists/)
+ expect { subject }.to raise_error(/Directory exists/)
end
it 'instruments the pending migration' do
@@ -344,7 +346,7 @@ RSpec.describe 'gitlab:db namespace rake task' do
it 'writes observations out to JSON file' do
subject
- expect(buffer.string).to eq(observations.to_json)
+ expect(File.read(File.join(directory, filename))).to eq(observations.to_json)
end
end
diff --git a/spec/views/layouts/nav/sidebar/_group.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_group.html.haml_spec.rb
index 640f463b45d..d46ba86a22e 100644
--- a/spec/views/layouts/nav/sidebar/_group.html.haml_spec.rb
+++ b/spec/views/layouts/nav/sidebar/_group.html.haml_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'layouts/nav/sidebar/_group' do
- let(:group) { create(:group) }
+ let_it_be(:group) { create(:group) }
before do
assign(:group, group)
@@ -11,4 +11,36 @@ RSpec.describe 'layouts/nav/sidebar/_group' do
it_behaves_like 'has nav sidebar'
it_behaves_like 'sidebar includes snowplow attributes', 'render', 'groups_side_navigation', 'groups_side_navigation'
+
+ describe 'Group information' do
+ it 'has a link to the group path' do
+ render
+
+ expect(rendered).to have_link('Group information', href: group_path(group))
+ end
+
+ it 'does not have a link to the details menu item' do
+ render
+
+ expect(rendered).not_to have_link('Details', href: details_group_path(group))
+ end
+
+ context 'when feature flag :sidebar_refactor is disabled' do
+ before do
+ stub_feature_flags(sidebar_refactor: false)
+ end
+
+ it 'has a link to the group path with the "Group overview" title' do
+ render
+
+ expect(rendered).to have_link('Group overview', href: group_path(group))
+ end
+
+ it 'has a link to the details menu item' do
+ render
+
+ expect(rendered).to have_link('Details', href: details_group_path(group))
+ end
+ end
+ end
end
diff --git a/yarn.lock b/yarn.lock
index 31eca04db6f..e510df1873c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -907,10 +907,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/tributejs/-/tributejs-1.0.0.tgz#672befa222aeffc83e7d799b0500a7a4418e59b8"
integrity sha512-nmKw1+hB6MHvlmPz63yPwVs1qQkycHwsKgxpEbzmky16Y6mL4EJMk3w1b8QlOAF/AIAzjCERPhe/R4MJiohbZw==
-"@gitlab/ui@29.13.0":
- version "29.13.0"
- resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-29.13.0.tgz#6e222106a0ae14f56c361b0cc86152d09170cc09"
- integrity sha512-JZAIuYT9gUhv/My/+IVwbBacTJAL+9g7wZWfSl9DS8PY/H2GCGgMcgvcSJMDuqcJZvKZdNkQ0XzXem+SFo5t1A==
+"@gitlab/ui@29.14.0":
+ version "29.14.0"
+ resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-29.14.0.tgz#0b5dc564fa26194ddbea6fe78418dc46c0e557ac"
+ integrity sha512-SYRokscvZD/F0TFa2gc0CgBtLeBlv4mPDhGPQUvh6uaX68NgMx9CstfYb286j5dKlvqBw+7r83fMiAHEzpberw==
dependencies:
"@babel/standalone" "^7.0.0"
"@gitlab/vue-toasted" "^1.3.0"