diff --git a/.rubocop_todo/rspec/named_subject.yml b/.rubocop_todo/rspec/named_subject.yml index cda1821ec75..3c3388bcf04 100644 --- a/.rubocop_todo/rspec/named_subject.yml +++ b/.rubocop_todo/rspec/named_subject.yml @@ -2479,7 +2479,6 @@ RSpec/NamedSubject: - 'spec/models/import_failure_spec.rb' - 'spec/models/incident_management/project_incident_management_setting_spec.rb' - 'spec/models/instance_configuration_spec.rb' - - 'spec/models/integrations/apple_app_store_spec.rb' - 'spec/models/integrations/asana_spec.rb' - 'spec/models/integrations/bamboo_spec.rb' - 'spec/models/integrations/bugzilla_spec.rb' diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 291f4a8b757..1a53db2560f 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -dcf5bf0826eccb45b637d531ee2c7dd646ece335 +dd6ef2a257606d4499cab51c80966a474f1a840f diff --git a/GITLAB_KAS_VERSION b/GITLAB_KAS_VERSION index 72642e66b2c..68f99c147a8 100644 --- a/GITLAB_KAS_VERSION +++ b/GITLAB_KAS_VERSION @@ -1 +1 @@ -ba307e0e52af8fbd06a1652d46515c04fde171de +b3fde99388ad67234fc0a096d1ece4c41988edac diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue index 2c55fb96670..b0b2e545e61 100644 --- a/app/assets/javascripts/clusters_list/components/agent_table.vue +++ b/app/assets/javascripts/clusters_list/components/agent_table.vue @@ -13,9 +13,6 @@ import { GlDisclosureDropdownItem, GlModalDirective, } from '@gitlab/ui'; -import semverLt from 'semver/functions/lt'; -import semverInc from 'semver/functions/inc'; -import semverPrerelease from 'semver/functions/prerelease'; import { __, s__, sprintf } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; @@ -66,7 +63,6 @@ export default { configHelpLink: helpPagePath('user/clusters/agent/install/index', { anchor: 'create-an-agent-configuration-file', }), - inject: ['kasCheckVersion'], props: { agents: { required: true, @@ -145,8 +141,8 @@ export default { } return this.agents.map((agent) => { - const versions = this.getAgentVersions(agent); - return { ...agent, versions }; + const { versions, warnings } = this.getAgentVersions(agent); + return { ...agent, versions, warnings }; }); }, showPagination() { @@ -179,14 +175,27 @@ export default { getAgentConfigPath, getAgentVersions(agent) { const agentConnections = agent.connections?.nodes || []; + const versions = []; + const warnings = []; - const agentVersions = agentConnections.map((agentConnection) => - agentConnection.metadata.version.replace('v', ''), - ); + agentConnections.forEach((connection) => { + const version = connection.metadata.version?.replace('v', ''); + if (version && !versions.includes(version)) { + versions.push(version); + } - const uniqueAgentVersions = [...new Set(agentVersions)]; + connection.warnings?.forEach((warning) => { + const message = warning?.version?.message; + if (message && !warnings.includes(message)) { + warnings.push(message); + } + }); + }); - return uniqueAgentVersions.sort((a, b) => a.localeCompare(b)); + return { + versions: versions.sort((a, b) => a.localeCompare(b)), + warnings, + }; }, getAgentVersionString(agent) { return agent.versions[0] || ''; @@ -194,37 +203,18 @@ export default { isVersionMismatch(agent) { return agent.versions.length > 1; }, - // isVersionOutdated determines if the agent version is outdated compared to the KAS / GitLab version - // using the following heuristics: - // - KAS Version is used as *server* version if available, otherwise the GitLab version is used. - // - returns `outdated` if the agent has a different major version than the server - // - returns `outdated` if the agents minor version is at least two proper versions older than the server - // - *proper* -> not a prerelease version. Meaning that server prereleases (with `-rcN`) suffix are counted as the previous minor version - // - // Note that it does NOT support if the agent is newer than the server version. - isVersionOutdated(agent) { - if (!agent.versions.length) return false; - - const agentVersion = this.getAgentVersionString(agent); - let allowableAgentVersion = semverInc(agentVersion, 'minor'); - - const isServerPrerelease = Boolean(semverPrerelease(this.kasCheckVersion)); - if (isServerPrerelease) { - allowableAgentVersion = semverInc(allowableAgentVersion, 'minor'); - } - - return semverLt(allowableAgentVersion, this.kasCheckVersion); + hasWarnings(agent) { + return agent.warnings.length > 0; }, - getVersionPopoverTitle(agent) { - if (this.isVersionMismatch(agent) && this.isVersionOutdated(agent)) { - return this.$options.i18n.versionMismatchOutdatedTitle; + if (this.isVersionMismatch(agent) && this.hasWarnings(agent)) { + return this.$options.i18n.versionWarningsMismatchTitle; } if (this.isVersionMismatch(agent)) { return this.$options.i18n.versionMismatchTitle; } - if (this.isVersionOutdated(agent)) { - return this.$options.i18n.versionOutdatedTitle; + if (this.hasWarnings(agent)) { + return this.$options.i18n.versionWarningsTitle; } return null; @@ -337,44 +327,31 @@ export default { {{ getAgentVersionString(item) }} -
-

{{ $options.i18n.versionMismatchText }}

- -

- - - - - {{ $options.i18n.viewDocsText }} -

-
-

+

{{ $options.i18n.versionMismatchText }}

- -

- - - +

+

+ {{ warning }} +

{{ $options.i18n.viewDocsText }} -

+
diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index 737b48eb2bb..cbddc8962b0 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -78,11 +78,8 @@ export const I18N_AGENT_TABLE = { versionMismatchText: s__( "ClusterAgents|The agent version do not match each other across your cluster's pods. This can happen when a new agent version was just deployed and Kubernetes is shutting down the old pods.", ), - versionOutdatedTitle: s__('ClusterAgents|Agent version update required'), - versionOutdatedText: s__( - 'ClusterAgents|Your agent version is out of sync with your GitLab KAS version (v%{version}), which might cause compatibility problems. Update the agent installed on your cluster to the most recent version.', - ), - versionMismatchOutdatedTitle: s__('ClusterAgents|Agent version mismatch and update'), + versionWarningsTitle: s__('ClusterAgents|Agent version update required'), + versionWarningsMismatchTitle: s__('ClusterAgents|Agent version mismatch and update'), viewDocsText: s__('ClusterAgents|How do I update an agent?'), defaultConfigText: s__('ClusterAgents|Default configuration'), defaultConfigTooltip: s__('ClusterAgents|What is default configuration?'), diff --git a/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql b/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql index 0d9e11b69fb..7c230ad45c7 100644 --- a/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql +++ b/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql @@ -15,6 +15,11 @@ fragment ClusterAgentFragment on ClusterAgent { metadata { version } + warnings { + version { + message + } + } } } tokens { diff --git a/app/assets/javascripts/clusters_list/index.js b/app/assets/javascripts/clusters_list/index.js index cee97d0562e..ce45259c1a2 100644 --- a/app/assets/javascripts/clusters_list/index.js +++ b/app/assets/javascripts/clusters_list/index.js @@ -30,7 +30,6 @@ export default () => { canAddCluster, canAdminCluster, kasInstallVersion, - kasCheckVersion, displayClusterAgents, certificateBasedClustersEnabled, } = el.dataset; @@ -49,7 +48,6 @@ export default () => { canAddCluster: parseBoolean(canAddCluster), canAdminCluster: parseBoolean(canAdminCluster), kasInstallVersion, - kasCheckVersion, displayClusterAgents: parseBoolean(displayClusterAgents), certificateBasedClustersEnabled: parseBoolean(certificateBasedClustersEnabled), }, diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue index 16a7f09e19b..76a73f3e083 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue @@ -11,7 +11,7 @@ import { import { isEmpty } from 'lodash'; // eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; - +import PageHeading from '~/vue_shared/components/page_heading.vue'; import { n__, s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; import { buildUrlWithCurrentLocation, historyPushState } from '~/lib/utils/common_utils'; @@ -34,6 +34,7 @@ export default { GlLink, GlSprintf, TablePagination, + PageHeading, }, directives: { GlModal: GlModalDirective, @@ -206,26 +207,26 @@ export default { {{ s__('FeatureFlags|New feature flag') }} -
-
-

+ + + + + + import { GlTable, GlLink, GlSprintf } from '@gitlab/ui'; -import ReadOnlyProjectBadge from 'ee_component/usage_quotas/storage/components/read_only_project_badge.vue'; +import ReadOnlyProjectBadge from 'ee_component/usage_quotas/storage/namespace/components/read_only_project_badge.vue'; import { __ } from '~/locale'; import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; import NumberToHumanSize from '~/vue_shared/components/number_to_human_size/number_to_human_size.vue'; import HelpPageLink from '~/vue_shared/components/help_page_link/help_page_link.vue'; import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue'; import { helpPagePath } from '~/helpers/help_page_helper'; -import StorageTypeHelpLink from './storage_type_help_link.vue'; -import StorageTypeWarning from './storage_type_warning.vue'; +import StorageTypeHelpLink from '../../components/storage_type_help_link.vue'; +import StorageTypeWarning from '../../components/storage_type_warning.vue'; export default { name: 'ProjectList', diff --git a/app/assets/javascripts/usage_quotas/storage/components/storage_usage_overview_card.vue b/app/assets/javascripts/usage_quotas/storage/namespace/components/storage_usage_overview_card.vue similarity index 100% rename from app/assets/javascripts/usage_quotas/storage/components/storage_usage_overview_card.vue rename to app/assets/javascripts/usage_quotas/storage/namespace/components/storage_usage_overview_card.vue diff --git a/app/assets/javascripts/usage_quotas/storage/components/storage_usage_statistics.vue b/app/assets/javascripts/usage_quotas/storage/namespace/components/storage_usage_statistics.vue similarity index 100% rename from app/assets/javascripts/usage_quotas/storage/components/storage_usage_statistics.vue rename to app/assets/javascripts/usage_quotas/storage/namespace/components/storage_usage_statistics.vue diff --git a/app/assets/javascripts/usage_quotas/storage/queries/namespace_storage.query.graphql b/app/assets/javascripts/usage_quotas/storage/namespace/queries/namespace_storage.query.graphql similarity index 100% rename from app/assets/javascripts/usage_quotas/storage/queries/namespace_storage.query.graphql rename to app/assets/javascripts/usage_quotas/storage/namespace/queries/namespace_storage.query.graphql diff --git a/app/assets/javascripts/usage_quotas/storage/queries/project_list_storage.query.graphql b/app/assets/javascripts/usage_quotas/storage/namespace/queries/project_list_storage.query.graphql similarity index 100% rename from app/assets/javascripts/usage_quotas/storage/queries/project_list_storage.query.graphql rename to app/assets/javascripts/usage_quotas/storage/namespace/queries/project_list_storage.query.graphql diff --git a/app/assets/javascripts/usage_quotas/storage/tab_metadata.js b/app/assets/javascripts/usage_quotas/storage/tab_metadata.js index 9497d147ba2..17411bd0f00 100644 --- a/app/assets/javascripts/usage_quotas/storage/tab_metadata.js +++ b/app/assets/javascripts/usage_quotas/storage/tab_metadata.js @@ -9,7 +9,7 @@ import { PROFILE_VIEW_TYPE, STORAGE_TAB_METADATA_EL_SELECTOR, } from '../constants'; -import NamespaceStorageApp from './components/namespace_storage_app.vue'; +import NamespaceStorageApp from './namespace/components/namespace_storage_app.vue'; import ProjectStorageApp from './project/components/project_storage_app.vue'; const parseProjectProvideData = (el) => { diff --git a/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.stories.js b/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.stories.js index 179b5c1a57f..67fcbc92e00 100644 --- a/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.stories.js +++ b/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.stories.js @@ -15,24 +15,36 @@ const Template = (args, { argTypes }) => ({ export const Default = Template.bind({}); Default.args = { editable: false, - panels: [ - { - component: 'CubeLineChart', - title: s__('ProductAnalytics|Audience'), - gridAttributes: { - width: 3, - height: 3, + initialDashboard: { + title: 'Dashboard', + description: 'Test description', + panels: [ + { + component: 'CubeLineChart', + title: s__('ProductAnalytics|Audience'), + gridAttributes: { + width: 3, + height: 3, + }, }, - }, - { - component: 'CubeLineChart', - title: s__('ProductAnalytics|Audience'), - gridAttributes: { - width: 3, - height: 3, + { + component: 'CubeLineChart', + title: s__('ProductAnalytics|Audience'), + gridAttributes: { + width: 3, + height: 3, + }, }, - }, - ], + ], + userDefined: true, + status: null, + errors: null, + }, + availableVisualizations: { + loading: true, + hasError: false, + visualizations: [], + }, }; export const Editable = Template.bind({}); diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue index 0bdde3cb65d..8a16e6e1e11 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -22,10 +22,14 @@ import { NEW_WORK_ITEM_IID, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_DESCRIPTION, + ROUTES, } from '../constants'; import WorkItemDescriptionRendered from './work_item_description_rendered.vue'; import WorkItemDescriptionTemplateListbox from './work_item_description_template_listbox.vue'; +const paramName = 'description_template'; +const oldParamNameFromPreWorkItems = 'issuable_template'; + export default { components: { EditedAt, @@ -114,63 +118,6 @@ export default { showTemplateApplyWarning: false, }; }, - apollo: { - workItem: { - query: workItemByIidQuery, - skip() { - return !this.workItemIid; - }, - variables() { - return { - fullPath: this.workItemFullPath, - iid: this.workItemIid, - }; - }, - update(data) { - return data?.workspace?.workItem || {}; - }, - result() { - if (this.isEditing && !this.createFlow) { - this.checkForConflicts(); - } - if (this.isEditing && this.createFlow) { - this.startEditing(); - } - }, - error() { - this.$emit('error', i18n.fetchError); - }, - }, - descriptionTemplate: { - query: workItemDescriptionTemplateQuery, - skip() { - return !this.selectedTemplate; - }, - variables() { - return { - fullPath: this.fullPath, - name: this.selectedTemplate, - }; - }, - update(data) { - return data.namespace.workItemDescriptionTemplates.nodes[0] || {}; - }, - result() { - const isDirty = this.descriptionText !== this.workItemDescription?.description; - const isUnchangedTemplate = this.descriptionText === this.appliedTemplate; - const hasContent = this.descriptionText !== ''; - if (!isUnchangedTemplate && (isDirty || hasContent)) { - this.showTemplateApplyWarning = true; - } else { - this.applyTemplate(); - } - }, - error(e) { - Sentry.captureException(e); - this.$emit('error', s__('WorkItem|Unable to find selected template.')); - }, - }, - }, computed: { createFlow() { return this.workItemId === newWorkItemId(this.workItemTypeName); @@ -265,6 +212,9 @@ export default { const hasEditedTemplate = this.descriptionText !== this.appliedTemplate; return hasAppliedTemplate && hasEditedTemplate; }, + shouldUpdateTemplateUrlParam() { + return this.$route?.name === ROUTES.new; + }, }, watch: { updateInProgress(newValue) { @@ -280,6 +230,69 @@ export default { } }, }, + mounted() { + if (this.shouldUpdateTemplateUrlParam) { + this.selectedTemplate = + this.$route.query[paramName] || this.$route.query[oldParamNameFromPreWorkItems]; + } + }, + apollo: { + workItem: { + query: workItemByIidQuery, + skip() { + return !this.workItemIid; + }, + variables() { + return { + fullPath: this.workItemFullPath, + iid: this.workItemIid, + }; + }, + update(data) { + return data?.workspace?.workItem || {}; + }, + result() { + if (this.isEditing && !this.createFlow) { + this.checkForConflicts(); + } + if (this.isEditing && this.createFlow) { + this.startEditing(); + } + }, + error() { + this.$emit('error', i18n.fetchError); + }, + }, + descriptionTemplate: { + query: workItemDescriptionTemplateQuery, + skip() { + return !this.selectedTemplate; + }, + variables() { + return { + fullPath: this.fullPath, + name: this.selectedTemplate, + }; + }, + update(data) { + return data.namespace.workItemDescriptionTemplates.nodes[0] || {}; + }, + result() { + const isDirty = this.descriptionText !== this.workItemDescription?.description; + const isUnchangedTemplate = this.descriptionText === this.appliedTemplate; + const hasContent = this.descriptionText !== ''; + if (!isUnchangedTemplate && (isDirty || hasContent)) { + this.showTemplateApplyWarning = true; + } else { + this.applyTemplate(); + } + }, + error(e) { + Sentry.captureException(e); + this.$emit('error', s__('WorkItem|Unable to find selected template.')); + }, + }, + }, methods: { checkForConflicts() { if (this.initialDescriptionText.trim() !== this.workItemDescription?.description.trim()) { @@ -359,11 +372,31 @@ export default { this.setDescriptionText(this.descriptionTemplateContent); this.onInput(); this.showTemplateApplyWarning = false; + + if (this.shouldUpdateTemplateUrlParam) { + const params = new URLSearchParams(this.$route.query); + params.delete(oldParamNameFromPreWorkItems); + params.set(paramName, this.selectedTemplate); + + this.$router.replace({ + query: Object.fromEntries(params), + }); + } }, cancelApplyTemplate() { this.selectedTemplate = ''; this.descriptionTemplate = null; this.showTemplateApplyWarning = false; + + if (this.shouldUpdateTemplateUrlParam) { + const params = new URLSearchParams(this.$route.query); + params.delete(paramName); + params.delete(oldParamNameFromPreWorkItems); + + this.$router.replace({ + query: Object.fromEntries(params), + }); + } }, handleClearTemplate() { if (this.appliedTemplate) { diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss index 339d486ffc3..f4023c3ca16 100644 --- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss +++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss @@ -51,8 +51,7 @@ } .top-area { - border-bottom: 1px solid $border-color; - display: flex; + @apply gl-flex gl-border-b; @include media-breakpoint-down(md) { flex-flow: column-reverse wrap; diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 41d200e85eb..32bbff498df 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -268,7 +268,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController perform_registration_tasks(@user, oauth['provider']) if new_user - enqueue_after_sign_in_workers(@user) + enqueue_after_sign_in_workers(@user, auth_user) sign_in_and_redirect_or_verify_identity(@user, auth_user, new_user) end @@ -444,7 +444,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController end # overridden in specific EE class - def enqueue_after_sign_in_workers(_user) + def enqueue_after_sign_in_workers(_user, _auth_user) true end diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb index 9194690ce82..a3b24c2d343 100644 --- a/app/controllers/projects/registry/repositories_controller.rb +++ b/app/controllers/projects/registry/repositories_controller.rb @@ -10,6 +10,10 @@ module Projects push_frontend_feature_flag(:show_container_registry_tag_signatures, project) end + before_action only: [:index, :show] do + push_frontend_feature_flag(:container_registry_protected_tags, project) + end + before_action :authorize_update_container_image!, only: [:destroy] def index diff --git a/app/controllers/projects/settings/packages_and_registries_controller.rb b/app/controllers/projects/settings/packages_and_registries_controller.rb index 589a95d81e1..98dd96e1ae5 100644 --- a/app/controllers/projects/settings/packages_and_registries_controller.rb +++ b/app/controllers/projects/settings/packages_and_registries_controller.rb @@ -37,7 +37,7 @@ module Projects end def set_feature_flag_container_registry_protected_tags - push_frontend_feature_flag(:container_registry_protected_tags, project.root_ancestor) + push_frontend_feature_flag(:container_registry_protected_tags, project) end end end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 35111be2b1b..3f47c6ee833 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -707,6 +707,13 @@ class ApplicationSetting < ApplicationRecord validates :sign_in_restrictions, json_schema: { filename: 'application_setting_sign_in_restrictions' } + jsonb_accessor :search, + global_search_merge_requests_enabled: [:boolean, { default: true }], + global_search_work_items_enabled: [:boolean, { default: true }], + global_search_users_enabled: [:boolean, { default: true }] + + validates :search, json_schema: { filename: 'application_setting_search' } + jsonb_accessor :transactional_emails, resource_access_token_notify_inherited: [:boolean, { default: false }], lock_resource_access_token_notify_inherited: [:boolean, { default: false }] diff --git a/app/models/integrations/field.rb b/app/models/integrations/field.rb index c04d28414cb..25687950e9a 100644 --- a/app/models/integrations/field.rb +++ b/app/models/integrations/field.rb @@ -12,7 +12,7 @@ module Integrations non_empty_password_title ].concat(BOOLEAN_ATTRIBUTES).freeze - TYPES = %i[text textarea password checkbox number select].freeze + TYPES = %i[text textarea password checkbox number string_array select].freeze attr_reader :name, :integration_class @@ -68,6 +68,8 @@ module Integrations ::API::Integrations::Boolean when :number Integer + when :string_array + Array[String] else String end diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb index 8ed2dee95cf..c9f3bb98ab0 100644 --- a/app/models/integrations/jira.rb +++ b/app/models/integrations/jira.rb @@ -78,6 +78,10 @@ module Integrations required: true, title: -> { s_('JiraService|Web URL') }, help: -> { s_('JiraService|Base URL of the Jira instance') }, + description: -> { + s_('JiraIntegration|The URL to the Jira project which is being linked to this GitLab project ' \ + '(for example, `https://jira.example.com`).') + }, placeholder: 'https://jira.example.com', exposes_secrets: true @@ -85,10 +89,14 @@ module Integrations section: SECTION_TYPE_CONNECTION, title: -> { s_('JiraService|Jira API URL') }, help: -> { s_('JiraService|If different from the Web URL') }, - exposes_secrets: true + exposes_secrets: true, + description: -> do + s_('JiraIntegration|The base URL to the Jira instance API. Web URL value is used if not set (for example, ' \ + '`https://jira-api.example.com`).') + end field :jira_auth_type, - type: :select, + type: :number, required: true, section: SECTION_TYPE_CONNECTION, title: -> { s_('JiraService|Authentication type') }, @@ -97,13 +105,21 @@ module Integrations [s_('JiraService|Basic'), AUTH_TYPE_BASIC], [s_('JiraService|Jira personal access token (Jira Data Center and Jira Server only)'), AUTH_TYPE_PAT] ] - } + }, + description: -> do + s_('JiraIntegration|The authentication method to use with Jira. Use `0` for Basic Authentication, ' \ + 'and `1` for Jira personal access token. Defaults to `0`.') + end field :username, section: SECTION_TYPE_CONNECTION, required: false, title: -> { s_('JiraService|Email or username') }, - help: -> { s_('JiraService|Email for Jira Cloud or username for Jira Data Center and Jira Server') } + help: -> { s_('JiraService|Email for Jira Cloud or username for Jira Data Center and Jira Server') }, + description: -> { + s_('JiraIntegration|The email or username to use with Jira. Use an email for Jira Cloud, and a username ' \ + 'for Jira Data Center and Jira Server. Required when using Basic Authentication (`jira_auth_type` is `0`).') + } field :password, section: SECTION_TYPE_CONNECTION, @@ -112,12 +128,19 @@ module Integrations non_empty_password_title: -> { s_('JiraService|New API token or password') }, non_empty_password_help: -> { s_('JiraService|Leave blank to use your current configuration') }, help: -> { s_('JiraService|API token for Jira Cloud or password for Jira Data Center and Jira Server') }, + description: -> { + s_('JiraIntegration|The Jira API token, password, or personal access token to use with Jira. When using ' \ + 'Basic Authentication (`jira_auth_type` is `0`), use an API token for Jira Cloud, and a password for ' \ + 'Jira Data Center or Jira Server. For a Jira personal access token ' \ + '(`jira_auth_type` is `1`), use the personal access token.') + }, is_secret: true field :jira_issue_regex, section: SECTION_TYPE_CONFIGURATION, required: false, title: -> { s_('JiraService|Jira issue regex') }, + description: -> { s_('JiraIntegration|Regular expression to match Jira issue keys.') }, help: -> do format(ERB::Util.html_escape( s_("JiraService|Use regular expression to match Jira issue keys. The regular expression must follow the " \ @@ -132,17 +155,31 @@ module Integrations section: SECTION_TYPE_CONFIGURATION, required: false, title: -> { s_('JiraService|Jira issue prefix') }, - help: -> { s_('JiraService|Use a prefix to match Jira issue keys.') } + help: -> { s_('JiraService|Use a prefix to match Jira issue keys.') }, + description: -> { s_('JiraIntegration|Prefix to match Jira issue keys.') } - field :jira_issue_transition_id, api_only: true + field :jira_issue_transition_id, + api_only: true, + description: -> { + s_('JiraIntegration|The ID of one or more transitions for ' \ + '[custom issue transitions](../integration/jira/issues.md#custom-issue-transitions).' \ + 'Ignored when `jira_issue_transition_automatic` is enabled. Defaults to a blank string,' \ + 'which disables custom transitions.') + } field :issues_enabled, required: false, - api_only: true + api_only: true, + description: -> { s_('JiraIntegration|Enable viewing Jira issues in GitLab.') } field :project_keys, required: false, - api_only: true + type: :string_array, + api_only: true, + description: -> { + s_('JiraIntegration|Keys of Jira projects. When `issues_enabled` is `true`, this setting specifies ' \ + 'which Jira projects to view issues from in GitLab.') + } # TODO: we can probably just delegate as part of # https://gitlab.com/gitlab-org/gitlab/issues/29404 diff --git a/app/validators/json_schemas/application_setting_search.json b/app/validators/json_schemas/application_setting_search.json new file mode 100644 index 00000000000..6f8ee71ab86 --- /dev/null +++ b/app/validators/json_schemas/application_setting_search.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Application Settings for search", + "type": "object", + "additionalProperties": true, + "properties": { + "global_search_work_items_enabled": { + "type": "boolean", + "description": "Enable global search for work items" + }, + "global_search_merge_requests_enabled": { + "type": "boolean", + "description": "Enable global search for merge requests" + }, + "global_search_users_enabled": { + "type": "boolean", + "description": "Enable global search for users" + } + } +} diff --git a/app/validators/json_schemas/approval_policies_licenses.json b/app/validators/json_schemas/approval_policies_licenses.json index 71066ed9d07..b9690cb59f1 100644 --- a/app/validators/json_schemas/approval_policies_licenses.json +++ b/app/validators/json_schemas/approval_policies_licenses.json @@ -54,7 +54,8 @@ "items": { "minLength": 1, "maxLength": 1024, - "type": "string" + "type": "string", + "format": "uri" } } }, diff --git a/app/views/explore/projects/_filter.html.haml b/app/views/explore/projects/_filter.html.haml index 45ec3c5d753..b5f0b7e3793 100644 --- a/app/views/explore/projects/_filter.html.haml +++ b/app/views/explore/projects/_filter.html.haml @@ -3,5 +3,5 @@ - if current_user - unless has_label - %span.gl-float-left.gl-whitespace-nowrap= _("Visibility:") + %span.gl-float-left.gl-whitespace-nowrap.gl-text-sm.gl-text-subtle.gl-my-3= _("Visibility:") = gl_redirect_listbox_tag(projects_filter_items, selected, class: 'gl-ml-3', data: { placement: 'right' }) diff --git a/app/views/explore/projects/topic.html.haml b/app/views/explore/projects/topic.html.haml index 2b31bc8fdd5..224b6530f2e 100644 --- a/app/views/explore/projects/topic.html.haml +++ b/app/views/explore/projects/topic.html.haml @@ -5,27 +5,32 @@ = render_dashboard_ultimate_trial(current_user) -.gl-pb-3.gl-pt-6 - %div{ class: container_class } - .gl-pb-5.gl-items-center.gl-flex - = render Pajamas::AvatarComponent.new(@topic, size: 64, alt: '') - - if @topic.title_or_name.length > max_topic_title_length - %h1.gl-mt-3.gl-ml-5.gl-str-truncated.has-tooltip{ title: @topic.title_or_name } - = truncate(@topic.title_or_name, length: max_topic_title_length) - - else - %h1.gl-mt-3.gl-ml-5 +%div{ class: container_class } + = render ::Layouts::PageHeadingComponent.new(_('Topics')) do |c| + - c.with_heading do + .gl-flex.gl-items-center.gl-gap-3 + = render Pajamas::AvatarComponent.new(@topic, size: 48, alt: '') + - if @topic.title_or_name.length > max_topic_title_length + %span.gl-str-truncated.has-tooltip{ title: @topic.title_or_name } + = truncate(@topic.title_or_name, length: max_topic_title_length) + - else = @topic.title_or_name + - if @topic.description.present? - .topic-description - = markdown(@topic.description) + - c.with_description do + .md= markdown(@topic.description) + + - c.with_actions do + = link_button_to nil, topic_explore_projects_path(@topic.name, rss_url_options), title: s_("Topics|Subscribe to the new projects feed"), aria: { label: s_("Topics|Subscribe to the new projects feed") }, class: 'gl-inline-flex has-tooltip', icon: 'rss' %div{ class: container_class } - .gl-py-5.gl-border-default.gl-border-b-solid.gl-border-b-1 - %h3.gl-m-0= _('Projects with this topic') - .top-area.gl-p-5.gl-justify-between.gl-bg-subtle - .nav-controls - = render 'shared/projects/search_form' + .top-area.gl-p-5.gl-justify-between.gl-bg-subtle.gl-border-t + .nav-controls.gl-w-full + = render 'shared/projects/search_form', topic_view: true = render 'filter' - = link_button_to nil, topic_explore_projects_path(@topic.name, rss_url_options), title: s_("Topics|Subscribe to the new projects feed"), class: 'gl-hidden sm:gl-inline-flex has-tooltip', icon: 'rss' + + .gl-flex.gl-flex-wrap.gl-gap-3.gl-justify-between.gl-items-center.gl-py-5.gl-w-full.gl-border-b.gl-border-b-subtle + %h2.gl-heading-4.gl-m-0= _('Projects with this topic') + = render 'shared/projects/dropdown', topic_view: true = render 'projects', projects: @projects diff --git a/app/views/projects/ml/candidates/show.html.haml b/app/views/projects/ml/candidates/show.html.haml index 41b29d3c0b8..7c5af852005 100644 --- a/app/views/projects/ml/candidates/show.html.haml +++ b/app/views/projects/ml/candidates/show.html.haml @@ -1,7 +1,7 @@ - experiment = @candidate.experiment - add_to_breadcrumbs _("Experiments"), project_ml_experiments_path(@project) - add_to_breadcrumbs experiment.name, project_ml_experiment_path(@project, experiment.iid) -- breadcrumb_title "Candidate #{@candidate.iid}" +- breadcrumb_title "Run #{@candidate.iid}" - add_page_specific_style 'page_bundles/ml_experiment_tracking' - presenter = ::Ml::CandidateDetailsPresenter.new(@candidate, current_user) diff --git a/app/views/shared/projects/_dropdown.html.haml b/app/views/shared/projects/_dropdown.html.haml index 28e9e14ef20..a96afe05bf0 100644 --- a/app/views/shared/projects/_dropdown.html.haml +++ b/app/views/shared/projects/_dropdown.html.haml @@ -1,5 +1,9 @@ - @sort ||= sort_value_latest_activity -.dropdown.js-project-filter-dropdown-wrap.gl-inline{ class: '!gl-m-0' } +- topic_view ||= false + +.dropdown.js-project-filter-dropdown-wrap.gl-inline-flex.gl-items-center.gl-gap-3{ class: '!gl-m-0' } + - if topic_view + %span.gl-text-sm.gl-text-subtle.gl-shrink-0= _("Sort by") + ":" = dropdown_toggle(projects_sort_options_hash[@sort], { toggle: 'dropdown', display: 'static' }, { id: 'sort-projects-dropdown' }) %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable %li.dropdown-header diff --git a/app/views/shared/projects/_search_form.html.haml b/app/views/shared/projects/_search_form.html.haml index da3787bfddb..09d6cb166c8 100644 --- a/app/views/shared/projects/_search_form.html.haml +++ b/app/views/shared/projects/_search_form.html.haml @@ -1,10 +1,11 @@ - placeholder = local_assigns[:search_form_placeholder] ? search_form_placeholder : _('Filter by name') - admin_view ||= false +- topic_view ||= false = form_tag filter_projects_path, method: :get, class: "project-filter-form !gl-flex gl-flex-wrap gl-w-full gl-gap-3", data: { testid: 'project-filter-form-container' }, id: 'project-filter-form' do |f| = search_field_tag :name, params[:name], placeholder: placeholder, - class: "project-filter-form-field form-control input-short js-projects-list-filter !gl-m-0 gl-grow", + class: "project-filter-form-field form-control input-short js-projects-list-filter !gl-m-0 gl-grow-[99]", spellcheck: false, id: 'project-filter-form-field', autofocus: local_assigns[:autofocus] @@ -27,7 +28,7 @@ - if params[:language].present? = hidden_field_tag :language, params[:language] - .dropdown{ class: '!gl-m-0' } + .dropdown{ class: '!gl-m-0 gl-grow' } = dropdown_toggle(search_language_placeholder, { toggle: 'dropdown', testid: 'project-language-dropdown' }) %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable %li @@ -39,7 +40,8 @@ = submit_tag nil, class: '!gl-hidden' - = render 'shared/projects/dropdown' + - if !topic_view + = render 'shared/projects/dropdown' = render_if_exists 'shared/projects/search_fields' diff --git a/config/application_setting_columns/search.yml b/config/application_setting_columns/search.yml new file mode 100644 index 00000000000..136c26bdfe9 --- /dev/null +++ b/config/application_setting_columns/search.yml @@ -0,0 +1,12 @@ +--- +api_type: +attr: search +clusterwide: false +column: search +db_type: jsonb +default: "'{}'::jsonb" +description: +encrypted: false +gitlab_com_different_than_default: true +jihu: false +not_null: true diff --git a/db/migrate/20250108114357_add_global_search_settings_to_application_settings.rb b/db/migrate/20250108114357_add_global_search_settings_to_application_settings.rb new file mode 100644 index 00000000000..7dc52cd77f1 --- /dev/null +++ b/db/migrate/20250108114357_add_global_search_settings_to_application_settings.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddGlobalSearchSettingsToApplicationSettings < Gitlab::Database::Migration[2.2] + milestone '17.9' + + def change + add_column :application_settings, :search, :jsonb, default: {}, null: false + end +end diff --git a/db/migrate/20250115161606_add_expires_at_to_vulnerability_exports.rb b/db/migrate/20250115161606_add_expires_at_to_vulnerability_exports.rb new file mode 100644 index 00000000000..a3859379beb --- /dev/null +++ b/db/migrate/20250115161606_add_expires_at_to_vulnerability_exports.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddExpiresAtToVulnerabilityExports < Gitlab::Database::Migration[2.2] + milestone '17.9' + + def change + add_column :vulnerability_exports, :expires_at, :timestamptz, if_not_exists: true + end +end diff --git a/db/schema_migrations/20250108114357 b/db/schema_migrations/20250108114357 new file mode 100644 index 00000000000..dd9d049d7a0 --- /dev/null +++ b/db/schema_migrations/20250108114357 @@ -0,0 +1 @@ +73b7e842e1d64c04554d34fa3e90e1cab9942f758e62321f77beb68da53909a3 \ No newline at end of file diff --git a/db/schema_migrations/20250115161606 b/db/schema_migrations/20250115161606 new file mode 100644 index 00000000000..afada70c850 --- /dev/null +++ b/db/schema_migrations/20250115161606 @@ -0,0 +1 @@ +a126673e4002772c4f07110faa90ac7a12c053821153708cbbd241ff7d0f7406 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 2a4f97f92d7..a75373665d0 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -7905,6 +7905,7 @@ CREATE TABLE application_settings ( elasticsearch_indexed_file_size_limit_kb integer DEFAULT 1024 NOT NULL, elasticsearch_max_code_indexing_concurrency integer DEFAULT 30 NOT NULL, observability_settings jsonb DEFAULT '{}'::jsonb NOT NULL, + search jsonb DEFAULT '{}'::jsonb NOT NULL, CONSTRAINT app_settings_container_reg_cleanup_tags_max_list_size_positive CHECK ((container_registry_cleanup_tags_service_max_list_size >= 0)), CONSTRAINT app_settings_dep_proxy_ttl_policies_worker_capacity_positive CHECK ((dependency_proxy_ttl_group_policy_worker_capacity >= 0)), CONSTRAINT app_settings_ext_pipeline_validation_service_url_text_limit CHECK ((char_length(external_pipeline_validation_service_url) <= 255)), @@ -22410,7 +22411,8 @@ CREATE TABLE vulnerability_exports ( file_store integer, format smallint DEFAULT 0 NOT NULL, group_id bigint, - organization_id bigint NOT NULL + organization_id bigint NOT NULL, + expires_at timestamp with time zone ); CREATE SEQUENCE vulnerability_exports_id_seq diff --git a/doc/api/admin/token.md b/doc/api/admin/token.md index 728ea34f7e4..7ec50c8daa4 100644 --- a/doc/api/admin/token.md +++ b/doc/api/admin/token.md @@ -31,6 +31,7 @@ Prerequisites: > - [Cluster agent tokens added](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/172932) in GitLab 17.7. > - [Runner authentication tokens added](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/173987) in GitLab 17.7. > - [Pipeline trigger tokens added](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/174030) in GitLab 17.7. +> - [CI/CD Job Tokens added](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/175234) in GitLab 17.9. Gets information for a given token. This endpoint supports the following tokens: @@ -42,6 +43,7 @@ Gets information for a given token. This endpoint supports the following tokens: - [Cluster agent tokens](../../security/tokens/index.md#gitlab-cluster-agent-tokens) - [Runner authentication tokens](../../security/tokens/index.md#runner-authentication-tokens) - [Pipeline trigger tokens](../../ci/triggers/index.md#create-a-pipeline-trigger-token) +- [CI/CD Job Tokens](../../security/tokens/index.md#cicd-job-tokens) ```plaintext POST /api/v4/admin/token diff --git a/doc/api/integrations.md b/doc/api/integrations.md index 3009a45a54a..4abdd39de9b 100644 --- a/doc/api/integrations.md +++ b/doc/api/integrations.md @@ -1341,14 +1341,14 @@ Parameters: | --------- | ---- | -------- | ----------- | | `url` | string | yes | The URL to the Jira project which is being linked to this GitLab project (for example, `https://jira.example.com`). | | `api_url` | string | no | The base URL to the Jira instance API. Web URL value is used if not set (for example, `https://jira-api.example.com`). | -| `username` | string | no | The email or username to be used with Jira. For Jira Cloud use an email, for Jira Data Center and Jira Server use a username. Required when using Basic authentication (`jira_auth_type` is `0`). | -| `password` | string | yes | The Jira API token, password, or personal access token to be used with Jira. When your authentication method is basic (`jira_auth_type` is `0`), use an API token for Jira Cloud or a password for Jira Data Center or Jira Server. When your authentication method is a Jira personal access token (`jira_auth_type` is `1`), use the personal access token. | +| `username` | string | no | The email or username to use with Jira. Use an email for Jira Cloud, and a username for Jira Data Center and Jira Server. Required when using Basic Authentication (`jira_auth_type` is `0`). | +| `password` | string | yes | The Jira API token, password, or personal access token to use with Jira. When using Basic Authentication (`jira_auth_type` is `0`), use an API token for Jira Cloud, and a password for Jira Data Center or Jira Server. For a Jira personal access token (`jira_auth_type` is `1`), use the personal access token. | | `active` | boolean | no | Activates or deactivates the integration. Defaults to `false` (deactivated). | -| `jira_auth_type`| integer | no | The authentication method to be used with Jira. `0` means Basic Authentication. `1` means Jira personal access token. Defaults to `0`. | +| `jira_auth_type`| integer | no | The authentication method to use with Jira. Use `0` for Basic Authentication, and `1` for Jira personal access token. Defaults to `0`. | | `jira_issue_prefix` | string | no | Prefix to match Jira issue keys. | | `jira_issue_regex` | string | no | Regular expression to match Jira issue keys. | | `jira_issue_transition_automatic` | boolean | no | Enable [automatic issue transitions](../integration/jira/issues.md#automatic-issue-transitions). Takes precedence over `jira_issue_transition_id` if enabled. Defaults to `false`. | -| `jira_issue_transition_id` | string | no | The ID of one or more transitions for [custom issue transitions](../integration/jira/issues.md#custom-issue-transitions). Ignored if `jira_issue_transition_automatic` is enabled. Defaults to a blank string, which disables custom transitions. | +| `jira_issue_transition_id` | string | no | The ID of one or more transitions for [custom issue transitions](../integration/jira/issues.md#custom-issue-transitions).Ignored when `jira_issue_transition_automatic` is enabled. Defaults to a blank string,which disables custom transitions. | | `commit_events` | boolean | no | Enable notifications for commit events. | | `merge_requests_events` | boolean | no | Enable notifications for merge request events. | | `comment_on_event_enabled` | boolean | no | Enable comments in Jira issues on each GitLab event (commit or merge request). | diff --git a/doc/development/integrations/index.md b/doc/development/integrations/index.md index fbd5b799552..f2917a6b616 100644 --- a/doc/development/integrations/index.md +++ b/doc/development/integrations/index.md @@ -216,7 +216,7 @@ This method should return an array of hashes for each field, where the keys can | Key | Type | Required | Default | Description | |:---------------|:--------|:---------|:-----------------------------|:--| -| `type:` | symbol | true | `:text` | The type of the form field. Can be `:text`, `:number`, `:textarea`, `:password`, `:checkbox`, or `:select`. | +| `type:` | symbol | true | `:text` | The type of the form field. Can be `:text`, `:number`, `:textarea`, `:password`, `:checkbox`, `:string_array` or `:select`. | | `section:` | symbol | false | | Specify which section the field belongs to. | | `name:` | string | true | | The property name for the form field. | | `required:` | boolean | false | `false` | Specify if the form field is required or optional. Note [backend validations](#define-validations) for presence are still needed. | diff --git a/doc/user/project/issues/issue_work_items.md b/doc/user/project/issues/issue_work_items.md new file mode 100644 index 00000000000..c5cc8668b30 --- /dev/null +++ b/doc/user/project/issues/issue_work_items.md @@ -0,0 +1,51 @@ +--- +stage: Plan +group: Project Management +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments +--- + +# Test a new look for issues + +DETAILS: +**Tier:** Free, Premium, Ultimate +**Offering:** GitLab.com, GitLab Self-Managed, GitLab Dedicated +**Status:** Beta + +> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/9584) in GitLab 17.5 [with a flag](../../../administration/feature_flags.md) named `work_items_view_preference`. Disabled by default. This feature is in [beta](../../../policy/development_stages_support.md#beta). + +FLAG: +The availability of this feature is controlled by a feature flag. +For more information, see the history. + + + +We have changed how issues look by migrating them to a unified framework for work items to better +meet the product needs of our Agile Planning offering. + +These changes include a new drawer view of issues opened from the issue list or issue board, a new creation workflow for issues and incidents, and a new view for issues. + +For more information, see [epic 9584](https://gitlab.com/groups/gitlab-org/-/epics/9584) and the +blog post +[First look: The new Agile planning experience in GitLab](https://about.gitlab.com/blog/2024/06/18/first-look-the-new-agile-planning-experience-in-gitlab/) (June 2024). + +## Troubleshooting + +If you're participating in a pilot of the new issues experience, you can disable the new view. +To do so, in the top right of an issue, select **New issue view**. + +## Feedback + +### Customers + +Customers participating in the pilot program, or who have voluntarily enabled the new experience, can leave feedback in [issue 513408](https://gitlab.com/gitlab-org/gitlab/-/issues/513408). + +### Internal users + +GitLab team members using the new experience should leave feedback in confidential issue +`https://gitlab.com/gitlab-org/gitlab/-/issues/512715`. + +## Related topics + +- [Work items development](../../../development/work_items.md) +- [Test a new look for epics](../../group/epics/epic_work_items.md) diff --git a/lib/api/entities/ci/job_token.rb b/lib/api/entities/ci/job_token.rb new file mode 100644 index 00000000000..ebe8d0db78a --- /dev/null +++ b/lib/api/entities/ci/job_token.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module API + module Entities + module Ci + class JobToken < Grape::Entity + expose :job, with: ::API::Entities::Ci::JobBasic + + def job + object + end + end + end + end +end diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index c100ed43837..5f8d88293f6 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -371,6 +371,10 @@ module API forbidden! unless current_user.can_admin_all_resources? end + def authorize_read_application_statistics! + authenticated_as_admin! + end + def authorize!(action, subject = :global, reason = nil) forbidden!(reason) unless can?(current_user, action, subject) end diff --git a/lib/api/helpers/integrations_helpers.rb b/lib/api/helpers/integrations_helpers.rb index bb64eaeadcf..3ac444c4f85 100644 --- a/lib/api/helpers/integrations_helpers.rb +++ b/lib/api/helpers/integrations_helpers.rb @@ -113,80 +113,7 @@ module API 'harbor' => ::Integrations::Harbor.api_arguments, 'irker' => ::Integrations::Irker.api_arguments, 'jenkins' => ::Integrations::Jenkins.api_arguments, - 'jira' => [ - { - required: true, - name: :url, - type: String, - desc: 'The base URL to the Jira instance web interface which is being linked to this GitLab project. E.g., https://jira.example.com' - }, - { - required: false, - name: :api_url, - type: String, - desc: 'The base URL to the Jira instance API. Web URL value will be used if not set. E.g., https://jira-api.example.com' - }, - { - required: false, - name: :jira_auth_type, - type: Integer, - desc: 'The authentication method to be used with Jira. `0` means Basic Authentication. `1` means Jira personal access token. Defaults to `0`' - }, - { - required: false, - name: :username, - type: String, - desc: 'The email or username to be used with Jira. For Jira Cloud use an email, for Jira Data Center and Jira Server use a username. Required when using Basic authentication (`jira_auth_type` is `0`)' - }, - { - required: true, - name: :password, - type: String, - desc: 'The Jira API token, password, or personal access token to be used with Jira. When your authentication method is Basic (`jira_auth_type` is `0`) use an API token for Jira Cloud, or a password for Jira Data Center or Jira Server. When your authentication method is Jira personal access token (`jira_auth_type` is `1`) use a personal access token' - }, - { - required: false, - name: :jira_issue_transition_automatic, - type: ::Grape::API::Boolean, - desc: 'Enable automatic issue transitions' - }, - { - required: false, - name: :jira_issue_transition_id, - type: String, - desc: 'The ID of one or more transitions for custom issue transitions' - }, - { - required: false, - name: :jira_issue_prefix, - type: String, - desc: 'Prefix to match Jira issue keys' - }, - { - required: false, - name: :jira_issue_regex, - type: String, - desc: 'Regular expression to match Jira issue keys' - }, - { - required: false, - name: :issues_enabled, - type: ::Grape::API::Boolean, - desc: 'Enable viewing Jira issues in GitLab' - }, - { - required: false, - name: :project_keys, - type: Array[String], - desc: 'Keys of Jira projects to view issues from in GitLab' - }, - { - required: false, - name: :comment_on_event_enabled, - type: ::Grape::API::Boolean, - desc: 'Enable comments inside Jira issues on each GitLab event (commit / merge request)' - } - ], + 'jira' => ::Integrations::Jira.api_arguments, 'jira-cloud-app' => ::Integrations::JiraCloudApp.api_arguments, 'matrix' => ::Integrations::Matrix.api_arguments, 'mattermost-slash-commands' => ::Integrations::MattermostSlashCommands.api_arguments, diff --git a/lib/api/ml/mlflow/registered_models.rb b/lib/api/ml/mlflow/registered_models.rb index 295d6c73b42..deb74ccc20c 100644 --- a/lib/api/ml/mlflow/registered_models.rb +++ b/lib/api/ml/mlflow/registered_models.rb @@ -169,6 +169,20 @@ module API present result, with: Entities::Ml::Mlflow::ListRegisteredModels end + + params do + optional :name, type: String, + desc: 'The name of the model' + optional :alias, type: String, + desc: 'The alias of the model, e.g. the Semantic Version `1.0.0`' + end + desc 'Gets a Model Version by alias' do + detail 'https://mlflow.org/docs/latest/rest-api.html#get-model-version-by-alias' + end + get 'alias', urgency: :low do + present find_model_version(user_project, params[:name], params[:alias]), + with: Entities::Ml::Mlflow::ModelVersion, root: :model_version + end end end end diff --git a/lib/api/statistics.rb b/lib/api/statistics.rb index 1af83c0737a..0ab4ea2d4a3 100644 --- a/lib/api/statistics.rb +++ b/lib/api/statistics.rb @@ -2,7 +2,7 @@ module API class Statistics < ::API::Base - before { authenticated_as_admin! } + before { authorize_read_application_statistics! } feature_category :devops_reports diff --git a/lib/authn/agnostic_token_identifier.rb b/lib/authn/agnostic_token_identifier.rb index 424401443e1..6d8c7cda707 100644 --- a/lib/authn/agnostic_token_identifier.rb +++ b/lib/authn/agnostic_token_identifier.rb @@ -11,7 +11,8 @@ module Authn ::Authn::Tokens::OauthApplicationSecret, ::Authn::Tokens::ClusterAgentToken, ::Authn::Tokens::RunnerAuthenticationToken, - ::Authn::Tokens::CiTriggerToken + ::Authn::Tokens::CiTriggerToken, + ::Authn::Tokens::CiJobToken ].freeze def self.token_for(plaintext, source) diff --git a/lib/authn/tokens/ci_job_token.rb b/lib/authn/tokens/ci_job_token.rb new file mode 100644 index 00000000000..1ccc1050e23 --- /dev/null +++ b/lib/authn/tokens/ci_job_token.rb @@ -0,0 +1,29 @@ +# frozen_string_literal:true + +module Authn + module Tokens + class CiJobToken + def self.prefix?(plaintext) + plaintext.start_with?(::Ci::Build::TOKEN_PREFIX) + end + + attr_reader :revocable, :source + + def initialize(plaintext, source) + @revocable = ::Ci::AuthJobFinder.new(token: plaintext).execute + + @source = source + end + + def present_with + ::API::Entities::Ci::JobToken + end + + def revoke!(_current_user) + raise ::Authn::AgnosticTokenIdentifier::NotFoundError, 'Not Found' if revocable.blank? + + raise ::Authn::AgnosticTokenIdentifier::UnsupportedTokenError, 'Unsupported token type' + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 7ced61bddc5..981e65a30f5 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1010,6 +1010,9 @@ msgstr "" msgid "%{labelStart}Project:%{labelEnd} %{project}" msgstr "" +msgid "%{labelStart}Report Type:%{labelEnd} %{reportType}" +msgstr "" + msgid "%{labelStart}Scanner:%{labelEnd} %{scanner}" msgstr "" @@ -1019,9 +1022,6 @@ msgstr "" msgid "%{labelStart}Severity:%{labelEnd} %{severity}" msgstr "" -msgid "%{labelStart}Tool:%{labelEnd} %{reportType}" -msgstr "" - msgid "%{labelStart}URL:%{labelEnd} %{url}" msgstr "" @@ -13225,9 +13225,6 @@ msgstr "" msgid "ClusterAgents|You will need to create a token to connect to your agent" msgstr "" -msgid "ClusterAgents|Your agent version is out of sync with your GitLab KAS version (v%{version}), which might cause compatibility problems. Update the agent installed on your cluster to the most recent version." -msgstr "" - msgid "ClusterAgents|Your instance doesn't have the %{linkStart}GitLab Agent Server (KAS)%{linkEnd} set up. Ask a GitLab Administrator to install it." msgstr "" @@ -31732,6 +31729,36 @@ msgstr "" msgid "JiraConnect|Your browser is not supported" msgstr "" +msgid "JiraIntegration|Enable viewing Jira issues in GitLab." +msgstr "" + +msgid "JiraIntegration|Keys of Jira projects. When `issues_enabled` is `true`, this setting specifies which Jira projects to view issues from in GitLab." +msgstr "" + +msgid "JiraIntegration|Prefix to match Jira issue keys." +msgstr "" + +msgid "JiraIntegration|Regular expression to match Jira issue keys." +msgstr "" + +msgid "JiraIntegration|The ID of one or more transitions for [custom issue transitions](../integration/jira/issues.md#custom-issue-transitions).Ignored when `jira_issue_transition_automatic` is enabled. Defaults to a blank string,which disables custom transitions." +msgstr "" + +msgid "JiraIntegration|The Jira API token, password, or personal access token to use with Jira. When using Basic Authentication (`jira_auth_type` is `0`), use an API token for Jira Cloud, and a password for Jira Data Center or Jira Server. For a Jira personal access token (`jira_auth_type` is `1`), use the personal access token." +msgstr "" + +msgid "JiraIntegration|The URL to the Jira project which is being linked to this GitLab project (for example, `https://jira.example.com`)." +msgstr "" + +msgid "JiraIntegration|The authentication method to use with Jira. Use `0` for Basic Authentication, and `1` for Jira personal access token. Defaults to `0`." +msgstr "" + +msgid "JiraIntegration|The base URL to the Jira instance API. Web URL value is used if not set (for example, `https://jira-api.example.com`)." +msgstr "" + +msgid "JiraIntegration|The email or username to use with Jira. Use an email for Jira Cloud, and a username for Jira Data Center and Jira Server. Required when using Basic Authentication (`jira_auth_type` is `0`)." +msgstr "" + msgid "JiraRequest|A connection error occurred while connecting to Jira. Try your request again." msgstr "" @@ -47437,6 +47464,9 @@ msgstr "" msgid "Reports|New" msgstr "" +msgid "Reports|Report Type" +msgstr "" + msgid "Reports|See test results while the pipeline is running" msgstr "" @@ -47449,9 +47479,6 @@ msgstr "" msgid "Reports|Test summary results are being parsed" msgstr "" -msgid "Reports|Tool" -msgstr "" - msgid "Reports|View partial report" msgstr "" diff --git a/qa/qa/scenario/test/instance/all.rb b/qa/qa/scenario/test/instance/all.rb index b00cefc1808..92f7c3110d2 100644 --- a/qa/qa/scenario/test/instance/all.rb +++ b/qa/qa/scenario/test/instance/all.rb @@ -13,7 +13,7 @@ module QA include SharedAttributes pipeline_mappings test_on_cng: %w[cng-instance], - test_on_gdk: %w[gdk-instance gdk-instance-gitaly-transactions], + test_on_gdk: %w[gdk-instance gdk-instance-gitaly-transactions gdk-instance-ff-inverse], test_on_omnibus: %w[ instance praefect diff --git a/qa/tasks/ci.rake b/qa/tasks/ci.rake index ea0e1c94f50..07c0620f787 100644 --- a/qa/tasks/ci.rake +++ b/qa/tasks/ci.rake @@ -42,8 +42,10 @@ namespace :ci do next pipeline_creator.create_noop end + feature_flags_changes = QA::Tools::Ci::FfChanges.new(diff).fetch # on run-all label or framework changes do not infer specific tests - run_all_tests = run_all_label_present || qa_changes.framework_changes? + run_all_tests = run_all_label_present || qa_changes.framework_changes? || + !feature_flags_changes.nil? tests = run_all_tests ? [] : qa_changes.qa_tests if run_all_label_present @@ -59,7 +61,7 @@ namespace :ci do creator_args = { pipeline_path: pipeline_path, logger: logger, - env: { "QA_FEATURE_FLAGS" => QA::Tools::Ci::FfChanges.new(diff).fetch } + env: { "QA_FEATURE_FLAGS" => feature_flags_changes } } logger.info("*** Creating E2E test pipeline definitions ***") diff --git a/spec/features/topic_show_spec.rb b/spec/features/topic_show_spec.rb index afccf48374f..b3d57db42f7 100644 --- a/spec/features/topic_show_spec.rb +++ b/spec/features/topic_show_spec.rb @@ -34,11 +34,11 @@ RSpec.describe 'Topic show page', :with_current_organization, feature_category: it 'shows title, avatar and description as markdown' do expect(page).to have_content(topic.title) expect(page).not_to have_content(topic.name) - expect(page).to have_selector('.gl-avatar.gl-avatar-s64') - expect(find('.topic-description')).to have_selector('p > strong') - expect(find('.topic-description')).to have_selector('p > a[rel]') - expect(find('.topic-description')).to have_selector('p > gl-emoji') - expect(find('.topic-description')).to have_selector('p > code') + expect(page).to have_selector('.gl-avatar.gl-avatar-s48') + expect(find('.md')).to have_selector('p > strong') + expect(find('.md')).to have_selector('p > a[rel]') + expect(find('.md')).to have_selector('p > gl-emoji') + expect(find('.md')).to have_selector('p > code') end context 'with associated projects' do diff --git a/spec/frontend/clusters_list/components/agent_table_spec.js b/spec/frontend/clusters_list/components/agent_table_spec.js index b363ab48126..dc267b203a4 100644 --- a/spec/frontend/clusters_list/components/agent_table_spec.js +++ b/spec/frontend/clusters_list/components/agent_table_spec.js @@ -8,7 +8,6 @@ import { GlDisclosureDropdown, GlDisclosureDropdownItem, } from '@gitlab/ui'; -import { sprintf } from '~/locale'; import AgentTable from '~/clusters_list/components/agent_table.vue'; import DeleteAgentButton from '~/clusters_list/components/delete_agent_button.vue'; import ConnectToAgentModal from '~/clusters_list/components/connect_to_agent_modal.vue'; @@ -22,22 +21,12 @@ import { clusterAgents, connectedTimeNow, connectedTimeInactive } from './mock_d const defaultConfigHelpUrl = '/help/user/clusters/agent/install/index#create-an-agent-configuration-file'; -const provideData = { - kasCheckVersion: '14.8.0', -}; const defaultProps = { agents: clusterAgents, maxAgents: null, }; -const DeleteAgentButtonStub = stubComponent(DeleteAgentButton, { - template: `
`, -}); - -const outdatedTitle = I18N_AGENT_TABLE.versionOutdatedTitle; -const mismatchTitle = I18N_AGENT_TABLE.versionMismatchTitle; -const mismatchOutdatedTitle = I18N_AGENT_TABLE.versionMismatchOutdatedTitle; -const mismatchText = I18N_AGENT_TABLE.versionMismatchText; +const DeleteAgentButtonStub = stubComponent(DeleteAgentButton, { template: '
' }); describe('AgentTable', () => { let wrapper; @@ -60,16 +49,11 @@ describe('AgentTable', () => { wrapper.findAllComponents(GlDisclosureDropdownItem).at(0); const findConnectModal = () => wrapper.findComponent(ConnectToAgentModal); - const createWrapper = ({ provide = provideData, propsData = defaultProps } = {}) => { + const createWrapper = ({ propsData = defaultProps } = {}) => { wrapper = mountExtended(AgentTable, { propsData, - provide, - stubs: { - DeleteAgentButton: DeleteAgentButtonStub, - }, - directives: { - GlModalDirective: createMockDirective('gl-modal-directive'), - }, + stubs: { DeleteAgentButton: DeleteAgentButtonStub }, + directives: { GlModalDirective: createMockDirective('gl-modal-directive') }, }); }; @@ -180,40 +164,22 @@ describe('AgentTable', () => { }); describe.each` - agentMockIdx | agentVersion | kasCheckVersion | versionMismatch | versionOutdated | title - ${0} | ${''} | ${'14.8.0'} | ${false} | ${false} | ${''} - ${1} | ${'14.8.0'} | ${'14.8.0'} | ${false} | ${false} | ${''} - ${2} | ${'14.6.0'} | ${'14.8.0'} | ${false} | ${true} | ${outdatedTitle} - ${3} | ${'14.7.0'} | ${'14.8.0'} | ${true} | ${false} | ${mismatchTitle} - ${4} | ${'14.3.0'} | ${'14.8.0'} | ${true} | ${true} | ${mismatchOutdatedTitle} - ${5} | ${'14.6.0'} | ${'14.8.0-rc1'} | ${false} | ${false} | ${''} - ${6} | ${'14.8.0'} | ${'15.0.0'} | ${false} | ${true} | ${outdatedTitle} - ${7} | ${'14.8.0'} | ${'15.0.0-rc1'} | ${false} | ${true} | ${outdatedTitle} - ${8} | ${'14.8.0'} | ${'14.8.10'} | ${false} | ${false} | ${''} - ${9} | ${''} | ${'14.8.0'} | ${false} | ${false} | ${''} + agentMockIdx | agentVersion | agentWarnings | versionMismatch | text | title + ${0} | ${''} | ${''} | ${false} | ${''} | ${''} + ${1} | ${'14.8.0'} | ${''} | ${false} | ${''} | ${''} + ${2} | ${'14.6.0'} | ${'This agent is outdated'} | ${false} | ${''} | ${I18N_AGENT_TABLE.versionWarningsTitle} + ${3} | ${'14.7.0'} | ${''} | ${true} | ${I18N_AGENT_TABLE.versionMismatchText} | ${I18N_AGENT_TABLE.versionMismatchTitle} + ${4} | ${'14.3.0'} | ${'This agent is outdated'} | ${true} | ${I18N_AGENT_TABLE.versionMismatchText} | ${I18N_AGENT_TABLE.versionWarningsMismatchTitle} `( - 'when agent version is "$agentVersion", KAS version is "$kasCheckVersion" and version mismatch is "$versionMismatch"', - ({ - agentMockIdx, - agentVersion, - kasCheckVersion, - versionMismatch, - versionOutdated, - title, - }) => { + 'when agent version is "$agentVersion" and agent warning is "$agentWarnings"', + ({ agentMockIdx, agentVersion, agentWarnings, versionMismatch, text, title }) => { const currentAgent = clusterAgents[agentMockIdx]; - - const findIcon = () => findVersionText(0).findComponent(GlIcon); - const findPopover = () => wrapper.findByTestId(`popover-${currentAgent.name}`); - - const versionWarning = versionMismatch || versionOutdated; - const outdatedText = sprintf(I18N_AGENT_TABLE.versionOutdatedText, { - version: kasCheckVersion, - }); + const showWarning = versionMismatch || agentWarnings?.length; + const popover = () => wrapper.findByTestId(`popover-${currentAgent.name}`); beforeEach(() => { createWrapper({ - provide: { kasCheckVersion, projectPath: 'path/to/project' }, + provide: { projectPath: 'path/to/project' }, propsData: { agents: [currentAgent] }, }); }); @@ -222,27 +188,19 @@ describe('AgentTable', () => { expect(findVersionText(0).text()).toBe(agentVersion); }); - if (versionWarning) { - it('shows a warning icon', () => { - expect(findIcon().props('name')).toBe('warning'); + if (showWarning) { + it('shows the correct title for the popover', () => { + expect(popover().props('title')).toBe(title); }); - it(`renders correct title for the popover when agent versions mismatch is ${versionMismatch} and outdated is ${versionOutdated}`, () => { - expect(findPopover().props('title')).toBe(title); + + it('renders correct text for the popover', () => { + expect(popover().text()).toContain(text); + expect(popover().text()).toContain(agentWarnings); }); - if (versionMismatch) { - it(`renders correct text for the popover when agent versions mismatch is ${versionMismatch}`, () => { - expect(findPopover().text()).toContain(mismatchText); - }); - } - if (versionOutdated) { - it(`renders correct text for the popover when agent versions outdated is ${versionOutdated}`, () => { - expect(findPopover().text()).toContain(outdatedText); - }); - } } else { - it(`doesn't show a warning icon with a popover when agent versions mismatch is ${versionMismatch} and outdated is ${versionOutdated}`, () => { - expect(findIcon().exists()).toBe(false); - expect(findPopover().exists()).toBe(false); + it("doesn't show a warning icon with a popover", () => { + expect(findVersionText(0).findComponent(GlIcon).exists()).toBe(false); + expect(popover().exists()).toBe(false); }); } }, diff --git a/spec/frontend/clusters_list/components/mock_data.js b/spec/frontend/clusters_list/components/mock_data.js index 24eb78a5701..29deea82c1b 100644 --- a/spec/frontend/clusters_list/components/mock_data.js +++ b/spec/frontend/clusters_list/components/mock_data.js @@ -51,9 +51,11 @@ export const clusterAgents = [ nodes: [ { metadata: { version: 'v14.8.0' }, + warnings: [], }, { metadata: { version: 'v14.8.0' }, + warnings: [], }, ], }, @@ -79,6 +81,7 @@ export const clusterAgents = [ nodes: [ { metadata: { version: 'v14.6.0' }, + warnings: [{ version: { message: 'This agent is outdated' } }], }, ], }, @@ -104,9 +107,11 @@ export const clusterAgents = [ nodes: [ { metadata: { version: 'v14.7.0' }, + warnings: [], }, { metadata: { version: 'v14.8.0' }, + warnings: [], }, ], }, @@ -132,9 +137,11 @@ export const clusterAgents = [ nodes: [ { metadata: { version: 'v14.5.0' }, + warnings: [{ version: { message: 'This agent is outdated' } }], }, { metadata: { version: 'v14.3.0' }, + warnings: [{ version: { message: 'This agent is outdated' } }], }, ], }, diff --git a/spec/frontend/fixtures/namespaces.rb b/spec/frontend/fixtures/namespaces.rb index 15d7d304035..1b9aed4ce31 100644 --- a/spec/frontend/fixtures/namespaces.rb +++ b/spec/frontend/fixtures/namespaces.rb @@ -36,8 +36,8 @@ RSpec.describe 'Namespaces (JavaScript fixtures)', feature_category: :groups_and describe 'Storage', feature_category: :consumables_cost_management do describe GraphQL::Query, type: :request do include GraphqlHelpers - base_input_path = 'usage_quotas/storage/queries/' - base_output_path = 'graphql/usage_quotas/storage/' + base_input_path = 'usage_quotas/storage/namespace/queries/' + base_output_path = 'graphql/usage_quotas/storage/namespace/' context 'for namespace storage statistics query' do before do diff --git a/spec/frontend/pages/sessions/new/username_validator_spec.js b/spec/frontend/pages/sessions/new/username_validator_spec.js new file mode 100644 index 00000000000..455dd81119f --- /dev/null +++ b/spec/frontend/pages/sessions/new/username_validator_spec.js @@ -0,0 +1,76 @@ +import UsernameValidator from '~/pages/sessions/new/username_validator'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import axios from '~/lib/utils/axios_utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import Tracking from '~/tracking'; +import { createAlert } from '~/alert'; + +jest.mock('~/alert'); +jest.mock('~/tracking'); + +describe('UsernameValidator', () => { + let input; + + beforeEach(() => { + setHTMLFixture(` +
+ + Success + Checking... + Username taken +
+ `); + + input = document.querySelector('.js-validate-username'); + // eslint-disable-next-line no-new + new UsernameValidator({ container: '.container' }); + input.value = 'testuser'; + }); + + afterEach(() => { + resetHTMLFixture(); + jest.resetAllMocks(); + }); + + describe('validateUsernameInput', () => { + it('shows pending message', () => { + jest.spyOn(axios, 'get').mockResolvedValue({ data: {} }); + input.dispatchEvent(new Event('input')); + expect(document.querySelector('.validation-pending').classList.contains('hide')).toBe(false); + }); + + it('shows success message and adds correct css class to input', async () => { + jest.spyOn(axios, 'get').mockResolvedValue({ data: { exists: false } }); + input.dispatchEvent(new Event('input')); + + await waitForPromises(); + + expect(input.classList.contains('gl-field-success-outline')).toBe(true); + expect(document.querySelector('.validation-success').classList.contains('hide')).toBe(false); + }); + + it('shows error message, adds correct css class to input and triggers tracking when username is taken', async () => { + jest.spyOn(axios, 'get').mockResolvedValue({ data: { exists: true } }); + input.dispatchEvent(new Event('input')); + + await waitForPromises(); + + expect(input.classList.contains('gl-field-error-outline')).toBe(true); + expect(document.querySelector('.validation-error').classList.contains('hide')).toBe(false); + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'track_username_error', { + label: 'username_is_taken', + }); + }); + + it('creates alert when axios request fails', async () => { + jest.spyOn(axios, 'get').mockRejectedValue(); + input.dispatchEvent(new Event('input')); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred while validating username', + }); + }); + }); +}); diff --git a/spec/frontend/usage_quotas/storage/mock_data.js b/spec/frontend/usage_quotas/storage/mock_data.js index 2fd5ffd9dc3..cebb1fe5dcd 100644 --- a/spec/frontend/usage_quotas/storage/mock_data.js +++ b/spec/frontend/usage_quotas/storage/mock_data.js @@ -1,6 +1,6 @@ import mockGetProjectStorageStatisticsGraphQLResponse from 'test_fixtures/graphql/usage_quotas/storage/project/project_storage.query.graphql.json'; -import mockGetNamespaceStorageGraphQLResponse from 'test_fixtures/graphql/usage_quotas/storage/namespace_storage.query.graphql.json'; -import mockGetProjectListStorageGraphQLResponse from 'test_fixtures/graphql/usage_quotas/storage/project_list_storage.query.graphql.json'; +import mockGetNamespaceStorageGraphQLResponse from 'test_fixtures/graphql/usage_quotas/storage/namespace/namespace_storage.query.graphql.json'; +import mockGetProjectListStorageGraphQLResponse from 'test_fixtures/graphql/usage_quotas/storage/namespace/project_list_storage.query.graphql.json'; import { storageTypeHelpPaths } from '~/usage_quotas/storage/constants'; export { mockGetProjectStorageStatisticsGraphQLResponse }; diff --git a/spec/frontend/usage_quotas/storage/components/container_registry_usage_spec.js b/spec/frontend/usage_quotas/storage/namespace/components/container_registry_usage_spec.js similarity index 92% rename from spec/frontend/usage_quotas/storage/components/container_registry_usage_spec.js rename to spec/frontend/usage_quotas/storage/namespace/components/container_registry_usage_spec.js index de10ea72f7d..a98b1a6352c 100644 --- a/spec/frontend/usage_quotas/storage/components/container_registry_usage_spec.js +++ b/spec/frontend/usage_quotas/storage/namespace/components/container_registry_usage_spec.js @@ -1,5 +1,5 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import ContainerRegistryUsage from '~/usage_quotas/storage/components/container_registry_usage.vue'; +import ContainerRegistryUsage from '~/usage_quotas/storage/namespace/components/container_registry_usage.vue'; import StorageTypeWarning from '~/usage_quotas/storage/components/storage_type_warning.vue'; describe('Container registry usage component', () => { diff --git a/spec/frontend/usage_quotas/storage/components/dependency_proxy_usage_spec.js b/spec/frontend/usage_quotas/storage/namespace/components/dependency_proxy_usage_spec.js similarity index 96% rename from spec/frontend/usage_quotas/storage/components/dependency_proxy_usage_spec.js rename to spec/frontend/usage_quotas/storage/namespace/components/dependency_proxy_usage_spec.js index c0bc92ed47f..1a254ad8e65 100644 --- a/spec/frontend/usage_quotas/storage/components/dependency_proxy_usage_spec.js +++ b/spec/frontend/usage_quotas/storage/namespace/components/dependency_proxy_usage_spec.js @@ -1,6 +1,6 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import HelpPageLink from '~/vue_shared/components/help_page_link/help_page_link.vue'; -import DependencyProxyUsage from '~/usage_quotas/storage/components/dependency_proxy_usage.vue'; +import DependencyProxyUsage from '~/usage_quotas/storage/namespace/components/dependency_proxy_usage.vue'; describe('Dependency proxy usage component', () => { /** @type {import('helpers/vue_test_utils_helper').ExtendedWrapper} */ diff --git a/spec/frontend/usage_quotas/storage/components/namespace_storage_app_spec.js b/spec/frontend/usage_quotas/storage/namespace/components/namespace_storage_app_spec.js similarity index 93% rename from spec/frontend/usage_quotas/storage/components/namespace_storage_app_spec.js rename to spec/frontend/usage_quotas/storage/namespace/components/namespace_storage_app_spec.js index 6a7ea636491..55c4296e318 100644 --- a/spec/frontend/usage_quotas/storage/components/namespace_storage_app_spec.js +++ b/spec/frontend/usage_quotas/storage/namespace/components/namespace_storage_app_spec.js @@ -5,21 +5,21 @@ import { cloneDeep } from 'lodash'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { captureException } from '~/ci/runner/sentry_utils'; -import NamespaceStorageApp from '~/usage_quotas/storage/components/namespace_storage_app.vue'; -import ProjectList from '~/usage_quotas/storage/components/project_list.vue'; -import getNamespaceStorageQuery from 'ee_else_ce/usage_quotas/storage/queries/namespace_storage.query.graphql'; -import getProjectListStorageQuery from 'ee_else_ce/usage_quotas/storage/queries/project_list_storage.query.graphql'; +import NamespaceStorageApp from '~/usage_quotas/storage/namespace/components/namespace_storage_app.vue'; +import ProjectList from '~/usage_quotas/storage/namespace/components/project_list.vue'; +import getNamespaceStorageQuery from 'ee_else_ce/usage_quotas/storage/namespace/queries/namespace_storage.query.graphql'; +import getProjectListStorageQuery from 'ee_else_ce/usage_quotas/storage/namespace/queries/project_list_storage.query.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import SearchAndSortBar from '~/usage_quotas/components/search_and_sort_bar/search_and_sort_bar.vue'; -import StorageUsageStatistics from '~/usage_quotas/storage/components/storage_usage_statistics.vue'; -import DependencyProxyUsage from '~/usage_quotas/storage/components/dependency_proxy_usage.vue'; -import ContainerRegistryUsage from '~/usage_quotas/storage/components/container_registry_usage.vue'; +import StorageUsageStatistics from '~/usage_quotas/storage/namespace/components/storage_usage_statistics.vue'; +import DependencyProxyUsage from '~/usage_quotas/storage/namespace/components/dependency_proxy_usage.vue'; +import ContainerRegistryUsage from '~/usage_quotas/storage/namespace/components/container_registry_usage.vue'; import { namespace, defaultNamespaceProvideValues, mockGetNamespaceStorageGraphQLResponse, mockGetProjectListStorageGraphQLResponse, -} from '../mock_data'; +} from '../../mock_data'; jest.mock('~/ci/runner/sentry_utils'); diff --git a/spec/frontend/usage_quotas/storage/components/project_list_spec.js b/spec/frontend/usage_quotas/storage/namespace/components/project_list_spec.js similarity index 96% rename from spec/frontend/usage_quotas/storage/components/project_list_spec.js rename to spec/frontend/usage_quotas/storage/namespace/components/project_list_spec.js index cdf6a39e0fc..b2b7e89de80 100644 --- a/spec/frontend/usage_quotas/storage/components/project_list_spec.js +++ b/spec/frontend/usage_quotas/storage/namespace/components/project_list_spec.js @@ -1,11 +1,11 @@ import { GlTable } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import ProjectList from '~/usage_quotas/storage/components/project_list.vue'; +import ProjectList from '~/usage_quotas/storage/namespace/components/project_list.vue'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import StorageTypeHelpLink from '~/usage_quotas/storage/components/storage_type_help_link.vue'; import StorageTypeWarning from '~/usage_quotas/storage/components/storage_type_warning.vue'; import { storageTypeHelpPaths } from '~/usage_quotas/storage/constants'; -import { defaultNamespaceProvideValues, projectList, storageTypes } from '../mock_data'; +import { defaultNamespaceProvideValues, projectList, storageTypes } from '../../mock_data'; /** @type {import('helpers/vue_test_utils_helper').ExtendedWrapper} */ let wrapper; diff --git a/spec/frontend/usage_quotas/storage/components/storage_usage_overview_card_spec.js b/spec/frontend/usage_quotas/storage/namespace/components/storage_usage_overview_card_spec.js similarity index 88% rename from spec/frontend/usage_quotas/storage/components/storage_usage_overview_card_spec.js rename to spec/frontend/usage_quotas/storage/namespace/components/storage_usage_overview_card_spec.js index 2dde568b1d2..7d295da3123 100644 --- a/spec/frontend/usage_quotas/storage/components/storage_usage_overview_card_spec.js +++ b/spec/frontend/usage_quotas/storage/namespace/components/storage_usage_overview_card_spec.js @@ -1,9 +1,9 @@ import { GlSkeletonLoader } from '@gitlab/ui'; import { numberToHumanSize } from '~/lib/utils/number_utils'; -import StorageUsageOverviewCard from '~/usage_quotas/storage/components/storage_usage_overview_card.vue'; +import StorageUsageOverviewCard from '~/usage_quotas/storage/namespace/components/storage_usage_overview_card.vue'; import NumberToHumanSize from '~/vue_shared/components/number_to_human_size/number_to_human_size.vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { mockGetNamespaceStorageGraphQLResponse } from '../mock_data'; +import { mockGetNamespaceStorageGraphQLResponse } from '../../mock_data'; describe('StorageUsageOverviewCard', () => { /** @type {import('helpers/vue_test_utils_helper').ExtendedWrapper} */ diff --git a/spec/frontend/usage_quotas/storage/components/storage_usage_statistics_spec.js b/spec/frontend/usage_quotas/storage/namespace/components/storage_usage_statistics_spec.js similarity index 79% rename from spec/frontend/usage_quotas/storage/components/storage_usage_statistics_spec.js rename to spec/frontend/usage_quotas/storage/namespace/components/storage_usage_statistics_spec.js index bb96a12aaf2..cce41a4f864 100644 --- a/spec/frontend/usage_quotas/storage/components/storage_usage_statistics_spec.js +++ b/spec/frontend/usage_quotas/storage/namespace/components/storage_usage_statistics_spec.js @@ -1,7 +1,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import StorageUsageStatistics from '~/usage_quotas/storage/components/storage_usage_statistics.vue'; -import StorageUsageOverviewCard from '~/usage_quotas/storage/components/storage_usage_overview_card.vue'; -import { mockGetNamespaceStorageGraphQLResponse } from '../mock_data'; +import StorageUsageStatistics from '~/usage_quotas/storage/namespace/components/storage_usage_statistics.vue'; +import StorageUsageOverviewCard from '~/usage_quotas/storage/namespace/components/storage_usage_overview_card.vue'; +import { mockGetNamespaceStorageGraphQLResponse } from '../../mock_data'; const defaultProps = { usedStorage: diff --git a/spec/frontend/work_items/components/work_item_description_spec.js b/spec/frontend/work_items/components/work_item_description_spec.js index e6668cda895..393a7f45114 100644 --- a/spec/frontend/work_items/components/work_item_description_spec.js +++ b/spec/frontend/work_items/components/work_item_description_spec.js @@ -16,6 +16,7 @@ import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutati import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import workItemDescriptionTemplateQuery from '~/work_items/graphql/work_item_description_template.query.graphql'; import { autocompleteDataSources, markdownPreviewPath, newWorkItemId } from '~/work_items/utils'; +import { ROUTES } from '~/work_items/constants'; import { updateWorkItemMutationResponse, workItemByIidResponseFactory, @@ -27,6 +28,7 @@ jest.mock('~/lib/utils/autosave'); describe('WorkItemDescription', () => { let wrapper; + let router; Vue.use(VueApollo); @@ -40,8 +42,8 @@ describe('WorkItemDescription', () => { const findDescriptionTemplateListbox = () => wrapper.findComponent(WorkItemDescriptionTemplatesListbox); const findDescriptionTemplateWarning = () => wrapper.findByTestId('description-template-warning'); - const findDescriptionTemplateWarningButton = (type) => - findDescriptionTemplateWarning().find(`[data-testid="template-${type}"]`); + const findApplyTemplate = () => wrapper.findByTestId('template-apply'); + const findCancelApplyTemplate = () => wrapper.findByTestId('template-cancel'); const editDescription = (newText) => findMarkdownEditor().vm.$emit('input', newText); @@ -76,7 +78,13 @@ describe('WorkItemDescription', () => { editMode = false, showButtonsBelowField, descriptionTemplateHandler = successfulTemplateHandler, + routeName = '', + routeQuery = {}, } = {}) => { + router = { + replace: jest.fn(), + }; + wrapper = shallowMountExtended(WorkItemDescription, { apolloProvider: createMockApollo([ [workItemByIidQuery, workItemResponseHandler], @@ -98,6 +106,13 @@ describe('WorkItemDescription', () => { workItemsAlpha: true, }, }, + mocks: { + $route: { + name: routeName, + query: routeQuery, + }, + $router: router, + }, stubs: { GlAlert, }, @@ -320,25 +335,25 @@ describe('WorkItemDescription', () => { it('displays a warning when a description template is selected', () => { expect(findDescriptionTemplateWarning().exists()).toBe(true); - expect(findDescriptionTemplateWarningButton('cancel').exists()).toBe(true); - expect(findDescriptionTemplateWarningButton('apply').exists()).toBe(true); + expect(findCancelApplyTemplate().exists()).toBe(true); + expect(findApplyTemplate().exists()).toBe(true); }); it('hides the warning when the cancel button is clicked', async () => { expect(findDescriptionTemplateWarning().exists()).toBe(true); - findDescriptionTemplateWarningButton('cancel').vm.$emit('click'); + findCancelApplyTemplate().vm.$emit('click'); await nextTick(); expect(findDescriptionTemplateWarning().exists()).toBe(false); }); it('applies the template when the apply button is clicked', async () => { - findDescriptionTemplateWarningButton('apply').vm.$emit('click'); + findApplyTemplate().vm.$emit('click'); await nextTick(); expect(findMarkdownEditor().props('value')).toBe('A template'); }); it('hides the warning when the template is applied', async () => { - findDescriptionTemplateWarningButton('apply').vm.$emit('click'); + findApplyTemplate().vm.$emit('click'); await nextTick(); expect(findDescriptionTemplateWarning().exists()).toBe(false); }); @@ -346,7 +361,7 @@ describe('WorkItemDescription', () => { describe('clearing a template', () => { it('sets the description to be empty when cleared', async () => { // apply a template - findDescriptionTemplateWarningButton('apply').vm.$emit('click'); + findApplyTemplate().vm.$emit('click'); await nextTick(); expect(findMarkdownEditor().props('value')).toBe('A template'); // clear the template @@ -360,7 +375,7 @@ describe('WorkItemDescription', () => { describe('resetting a template', () => { it('sets the description back to the original template value when reset', async () => { // apply a template - findDescriptionTemplateWarningButton('apply').vm.$emit('click'); + findApplyTemplate().vm.$emit('click'); // write something else findMarkdownEditor().vm.$emit('input', 'some other value'); await nextTick(); @@ -388,6 +403,134 @@ describe('WorkItemDescription', () => { expect(wrapper.emitted('error')).toEqual([['Unable to find selected template.']]); }); }); + + describe('URL param handling', () => { + describe('when on new work item route', () => { + beforeEach(async () => { + await createComponent({ + routeName: ROUTES.new, + routeQuery: { description_template: 'bug', other_param: 'some_value' }, + isEditing: true, + }); + }); + + it('sets selected template from URL on mount', () => { + expect(findDescriptionTemplateListbox().props().template).toBe('bug'); + }); + + it('updates URL when applying template', async () => { + findDescriptionTemplateListbox().vm.$emit('selectTemplate', 'example-template'); + await nextTick(); + await waitForPromises(); + + findApplyTemplate().vm.$emit('click'); + + expect(router.replace).toHaveBeenCalledWith({ + query: { + description_template: 'example-template', + other_param: 'some_value', + }, + }); + }); + + it('removes template param (and not other params) from URL when canceling template', async () => { + findDescriptionTemplateListbox().vm.$emit('selectTemplate', 'example-template'); + await nextTick(); + await waitForPromises(); + + findCancelApplyTemplate().vm.$emit('click'); + + expect(router.replace).toHaveBeenCalledWith({ + query: { + other_param: 'some_value', + }, + }); + }); + }); + + describe('issuable_template param', () => { + beforeEach(async () => { + await createComponent({ + routeName: ROUTES.new, + routeQuery: { issuable_template: 'my issue template', other_param: 'some_value' }, + isEditing: true, + }); + }); + + it('sets selected template from old template param', () => { + expect(findDescriptionTemplateListbox().props().template).toBe('my issue template'); + }); + + it('removes old template param on apply', async () => { + findDescriptionTemplateListbox().vm.$emit('selectTemplate', 'example-template'); + await nextTick(); + await waitForPromises(); + + findApplyTemplate().vm.$emit('click'); + + expect(router.replace).toHaveBeenCalledWith({ + query: { + description_template: 'example-template', + other_param: 'some_value', + }, + }); + }); + + it('removes old template param on cancel', async () => { + await createComponent({ + routeName: ROUTES.new, + routeQuery: { issuable_template: 'my issue template', other_param: 'some_value' }, + isEditing: true, + }); + + findDescriptionTemplateListbox().vm.$emit('selectTemplate', 'example-template'); + await nextTick(); + await waitForPromises(); + + findCancelApplyTemplate().vm.$emit('click'); + + expect(router.replace).toHaveBeenCalledWith({ + query: { + other_param: 'some_value', + }, + }); + }); + }); + + describe('when not on new work item route', () => { + beforeEach(async () => { + await createComponent({ + routeName: ROUTES.workItem, + routeQuery: { description_template: 'my issue template' }, + isEditing: true, + }); + }); + + it('does not set selected template from URL on mount', () => { + expect(findDescriptionTemplateListbox().props().template).toBe(''); + }); + + it('does not update URL when applying template', async () => { + findDescriptionTemplateListbox().vm.$emit('selectTemplate', 'example-template'); + await nextTick(); + await waitForPromises(); + + findApplyTemplate().vm.$emit('click'); + + expect(router.replace).not.toHaveBeenCalled(); + }); + + it('does not update URL when canceling template', async () => { + findDescriptionTemplateListbox().vm.$emit('selectTemplate', 'example-template'); + await nextTick(); + await waitForPromises(); + + findCancelApplyTemplate().vm.$emit('click'); + + expect(router.replace).not.toHaveBeenCalled(); + }); + }); + }); }); describe('when description has conflicts', () => { diff --git a/spec/lib/api/entities/ci/job_token_spec.rb b/spec/lib/api/entities/ci/job_token_spec.rb new file mode 100644 index 00000000000..020d6bd24fe --- /dev/null +++ b/spec/lib/api/entities/ci/job_token_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Entities::Ci::JobToken, feature_category: :continuous_integration do + let_it_be(:job) { create(:ci_build) } + + subject(:job_token_entity) { described_class.new(job).as_json } + + it "exposes job" do + expect(job_token_entity).to include(:job) + end +end diff --git a/spec/lib/authn/agnostic_token_identifier_spec.rb b/spec/lib/authn/agnostic_token_identifier_spec.rb index fd219560744..9f28c493153 100644 --- a/spec/lib/authn/agnostic_token_identifier_spec.rb +++ b/spec/lib/authn/agnostic_token_identifier_spec.rb @@ -3,6 +3,14 @@ require 'spec_helper' RSpec.describe Authn::AgnosticTokenIdentifier, feature_category: :system_access do + shared_examples 'supported token type' do + describe '#initialize' do + it 'finds the correct revocable token type' do + expect(token).to be_instance_of(token_type) + end + end + end + using RSpec::Parameterized::TableSyntax let_it_be(:user) { create(:user) } @@ -31,11 +39,29 @@ RSpec.describe Authn::AgnosticTokenIdentifier, feature_category: :system_access end with_them do - describe '#initialize' do - it 'finds the correct revocable token type' do - expect(token).to be_instance_of(token_type) - end - end + it_behaves_like 'supported token type' + end + end + + context 'with CI Job tokens' do + let(:plaintext) { create(:ci_build, status: status).token } + let(:token_type) { ::Authn::Tokens::CiJobToken } + + before do + rsa_key = OpenSSL::PKey::RSA.generate(3072).to_s + stub_application_setting(ci_jwt_signing_key: rsa_key) + end + + context 'when job is running' do + let(:status) { :running } + + it_behaves_like 'supported token type' + end + + context 'when job is not running' do + let(:status) { :success } + + it_behaves_like 'supported token type' end end end diff --git a/spec/lib/authn/tokens/ci_job_token_spec.rb b/spec/lib/authn/tokens/ci_job_token_spec.rb new file mode 100644 index 00000000000..e1049cf44e8 --- /dev/null +++ b/spec/lib/authn/tokens/ci_job_token_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Authn::Tokens::CiJobToken, feature_category: :system_access do + let_it_be(:user) { create(:user) } + let(:ci_build) { create(:ci_build, status: :running) } + let(:token_type) { ::Authn::Tokens::CiJobToken } + + subject(:token) { described_class.new(plaintext, :api_admin_token) } + + context 'with valid ci build token' do + let(:plaintext) { ci_build.token } + let(:valid_revocable) { ci_build } + + it_behaves_like 'finding the valid revocable' + + context 'when the job is not running' do + let(:ci_build) { create(:ci_build, status: :success) } + + it 'is not found' do + expect(token.revocable).to be_nil + end + end + + describe '#revoke!' do + it 'does not support revocation yet' do + expect do + token.revoke!(user) + end.to raise_error(::Authn::AgnosticTokenIdentifier::UnsupportedTokenError, 'Unsupported token type') + end + end + end + + it_behaves_like 'token handling with unsupported token type' +end diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index d498c4bb3b8..cde19ba6a9f 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -53,6 +53,9 @@ RSpec.describe ApplicationSetting, feature_category: :shared, type: :model do it { expect(setting.resource_access_token_notify_inherited).to be(false) } it { expect(setting.lock_resource_access_token_notify_inherited).to be(false) } it { expect(setting.ropc_without_client_credentials).to be(true) } + it { expect(setting.global_search_merge_requests_enabled).to be(true) } + it { expect(setting.global_search_work_items_enabled).to be(true) } + it { expect(setting.global_search_users_enabled).to be(true) } end describe 'USERS_UNCONFIRMED_SECONDARY_EMAILS_DELETE_AFTER_DAYS' do @@ -95,6 +98,7 @@ RSpec.describe ApplicationSetting, feature_category: :shared, type: :model do } end + it { expect(described_class).to validate_jsonb_schema(['application_setting_search']) } it { expect(described_class).to validate_jsonb_schema(['resource_usage_limits']) } it { expect(described_class).to validate_jsonb_schema(['application_setting_rate_limits']) } it { expect(described_class).to validate_jsonb_schema(['application_setting_package_registry']) } diff --git a/spec/models/integrations/apple_app_store_spec.rb b/spec/models/integrations/apple_app_store_spec.rb index ea66c382726..3713c2ac6e3 100644 --- a/spec/models/integrations/apple_app_store_spec.rb +++ b/spec/models/integrations/apple_app_store_spec.rb @@ -3,130 +3,126 @@ require 'spec_helper' RSpec.describe Integrations::AppleAppStore, feature_category: :mobile_devops do + subject(:integration) { build(:apple_app_store_integration) } + describe 'Validations' do context 'when active' do - before do - subject.active = true - end - it { is_expected.to validate_presence_of :app_store_issuer_id } it { is_expected.to validate_presence_of :app_store_key_id } it { is_expected.to validate_presence_of :app_store_private_key } it { is_expected.to validate_presence_of :app_store_private_key_file_name } it { is_expected.to validate_inclusion_of(:app_store_protected_refs).in_array([true, false]) } it { is_expected.to allow_value('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee').for(:app_store_issuer_id) } - it { is_expected.not_to allow_value('abcde').for(:app_store_issuer_id) } it { is_expected.to allow_value(File.read('spec/fixtures/ssl_key.pem')).for(:app_store_private_key) } - it { is_expected.not_to allow_value("foo").for(:app_store_private_key) } it { is_expected.to allow_value('ABCD1EF12G').for(:app_store_key_id) } + it { is_expected.not_to allow_value('abcde').for(:app_store_issuer_id) } + it { is_expected.not_to allow_value("foo").for(:app_store_private_key) } it { is_expected.not_to allow_value('ABC').for(:app_store_key_id) } it { is_expected.not_to allow_value('abc1').for(:app_store_key_id) } it { is_expected.not_to allow_value('-A0-').for(:app_store_key_id) } end + + context 'when inactive' do + before do + integration.deactivate! + end + + it { is_expected.not_to validate_presence_of :app_store_issuer_id } + it { is_expected.not_to validate_presence_of :app_store_key_id } + it { is_expected.not_to validate_presence_of :app_store_private_key } + it { is_expected.not_to validate_presence_of :app_store_private_key_file_name } + it { is_expected.not_to validate_inclusion_of(:app_store_protected_refs).in_array([true, false]) } + end end - context 'when integration is enabled' do - let(:apple_app_store_integration) { build(:apple_app_store_integration) } + describe '#fields' do + it 'returns custom fields' do + expect(integration.fields.pluck(:name)).to match_array(%w[app_store_issuer_id app_store_key_id + app_store_private_key app_store_private_key_file_name app_store_protected_refs]) + end + end - describe '#fields' do - it 'returns custom fields' do - expect(apple_app_store_integration.fields.pluck(:name)).to match_array(%w[app_store_issuer_id app_store_key_id - app_store_private_key app_store_private_key_file_name app_store_protected_refs]) - end + describe '#test' do + it 'returns true for a successful request' do + allow(AppStoreConnect::Client).to receive_message_chain(:new, :apps).and_return({}) + expect(integration.test[:success]).to be true end - describe '#test' do - it 'returns true for a successful request' do - allow(AppStoreConnect::Client).to receive_message_chain(:new, :apps).and_return({}) - expect(apple_app_store_integration.test[:success]).to be true - end + it 'returns false for an invalid request' do + allow(AppStoreConnect::Client).to receive_message_chain(:new, :apps) + .and_return({ errors: [title: "error title"] }) + expect(integration.test[:success]).to be false + end + end - it 'returns false for an invalid request' do - allow(AppStoreConnect::Client).to receive_message_chain(:new, :apps) - .and_return({ errors: [title: "error title"] }) - expect(apple_app_store_integration.test[:success]).to be false - end + describe '#help' do + it { expect(integration.help).not_to be_empty } + end + + describe '.to_param' do + it { expect(integration.to_param).to eq('apple_app_store') } + end + + describe '#ci_variables' do + let(:ci_vars) do + [ + { + key: 'APP_STORE_CONNECT_API_KEY_ISSUER_ID', + value: integration.app_store_issuer_id, + masked: true, + public: false + }, + { + key: 'APP_STORE_CONNECT_API_KEY_KEY', + value: Base64.encode64(integration.app_store_private_key), + masked: true, + public: false + }, + { + key: 'APP_STORE_CONNECT_API_KEY_KEY_ID', + value: integration.app_store_key_id, + masked: true, + public: false + }, + { + key: 'APP_STORE_CONNECT_API_KEY_IS_KEY_CONTENT_BASE64', + value: described_class::IS_KEY_CONTENT_BASE64, + masked: false, + public: false + } + ] end - describe '#help' do - it 'renders prompt information' do - expect(apple_app_store_integration.help).not_to be_empty - end + it 'returns the vars for protected branch' do + expect(integration.ci_variables(protected_ref: true)).to match_array(ci_vars) end - describe '.to_param' do - it 'returns the name of the integration' do - expect(described_class.to_param).to eq('apple_app_store') - end + it 'doesn\'t return the vars for unprotected branch' do + expect(integration.ci_variables(protected_ref: false)).to be_empty + end + end + + describe '#initialize_properties' do + context 'when `app_store_protected_refs` is nil' do + subject(:integration) { described_class.new(app_store_protected_refs: nil) } + + it { expect(integration.app_store_protected_refs).to be(true) } end - describe '#ci_variables' do - let(:apple_app_store_integration) { build_stubbed(:apple_app_store_integration) } + context 'when `app_store_protected_refs` is false' do + subject(:integration) { described_class.new(app_store_protected_refs: false) } - let(:ci_vars) do - [ - { - key: 'APP_STORE_CONNECT_API_KEY_ISSUER_ID', - value: apple_app_store_integration.app_store_issuer_id, - masked: true, - public: false - }, - { - key: 'APP_STORE_CONNECT_API_KEY_KEY', - value: Base64.encode64(apple_app_store_integration.app_store_private_key), - masked: true, - public: false - }, - { - key: 'APP_STORE_CONNECT_API_KEY_KEY_ID', - value: apple_app_store_integration.app_store_key_id, - masked: true, - public: false - }, - { - key: 'APP_STORE_CONNECT_API_KEY_IS_KEY_CONTENT_BASE64', - value: described_class::IS_KEY_CONTENT_BASE64, - masked: false, - public: false - } - ] - end - - it 'returns the vars for protected branch' do - expect(apple_app_store_integration.ci_variables(protected_ref: true)).to match_array(ci_vars) - end - - it 'doesn\'t return the vars for unprotected branch' do - expect(apple_app_store_integration.ci_variables(protected_ref: false)).to be_empty - end - end - - describe '#initialize_properties' do - context 'when app_store_protected_refs is nil' do - let(:apple_app_store_integration) { described_class.new(app_store_protected_refs: nil) } - - it 'sets app_store_protected_refs to true' do - expect(apple_app_store_integration.app_store_protected_refs).to be(true) - end - end - - context 'when app_store_protected_refs is false' do - let(:apple_app_store_integration) { build(:apple_app_store_integration, app_store_protected_refs: false) } - - it 'sets app_store_protected_refs to false' do - expect(apple_app_store_integration.app_store_protected_refs).to be(false) - end - end + it { expect(integration.app_store_protected_refs).to be(false) } end end context 'when integration is disabled' do - let(:apple_app_store_integration) { build_stubbed(:apple_app_store_integration, active: false) } + before do + integration.deactivate! + end describe '#ci_variables' do - it 'returns an empty array' do - expect(apple_app_store_integration.ci_variables(protected_ref: true)).to be_empty - end + it { expect(integration.ci_variables(protected_ref: true)).to be_empty } end end end diff --git a/spec/models/integrations/field_spec.rb b/spec/models/integrations/field_spec.rb index 636c10bc91d..2b9d8e36882 100644 --- a/spec/models/integrations/field_spec.rb +++ b/spec/models/integrations/field_spec.rb @@ -210,6 +210,16 @@ RSpec.describe ::Integrations::Field, feature_category: :integrations do expect(field.api_type).to eq(Integer) end end + + context 'when type is string_array' do + before do + attrs[:type] = :string_array + end + + it 'returns Array[String]' do + expect(field.api_type).to eq([String]) + end + end end describe '#key?' do diff --git a/spec/requests/api/admin/token_spec.rb b/spec/requests/api/admin/token_spec.rb index d38d1cb143e..f3f464b5c89 100644 --- a/spec/requests/api/admin/token_spec.rb +++ b/spec/requests/api/admin/token_spec.rb @@ -53,6 +53,7 @@ RSpec.describe API::Admin::Token, :aggregate_failures, feature_category: :system let(:runner_authentication_token) { create(:ci_runner, registration_type: :authenticated_user) } let(:impersonation_token) { create(:personal_access_token, :impersonation, user: user) } let(:ci_trigger) { create(:ci_trigger) } + let(:ci_build) { create(:ci_build, status: :running) } let(:plaintext) { nil } let(:params) { { token: plaintext } } @@ -86,6 +87,18 @@ RSpec.describe API::Admin::Token, :aggregate_failures, feature_category: :system end end + context 'with valid CI job token' do + let(:token) { ci_build } + let(:plaintext) { ci_build.token } + + it 'contains a job' do + post_token + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['job']['id']).to eq(ci_build.id) + end + end + it_behaves_like 'rejecting invalid requests with admin' end diff --git a/spec/requests/api/ml/mlflow/registered_models_spec.rb b/spec/requests/api/ml/mlflow/registered_models_spec.rb index a8f81a7641c..737629c0aa2 100644 --- a/spec/requests/api/ml/mlflow/registered_models_spec.rb +++ b/spec/requests/api/ml/mlflow/registered_models_spec.rb @@ -9,6 +9,8 @@ RSpec.describe API::Ml::Mlflow::RegisteredModels, feature_category: :mlops do create(:ml_models, :with_metadata, project: project) end + let_it_be(:model_version) { create(:ml_model_versions, project: project, model: model, version: '1.0.0') } + let_it_be(:tokens) do { write: create(:personal_access_token, scopes: %w[read_api api], user: developer), @@ -61,6 +63,52 @@ RSpec.describe API::Ml::Mlflow::RegisteredModels, feature_category: :mlops do end end + describe 'GET /projects/:id/ml/mlflow/api/2.0/mlflow/registered-models/alias' do + let(:model_name) { model_version.model.name } + let(:version) { model_version.version } + let(:route) do + "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/registered-models/alias?name=#{model_name}&alias=#{version}" + end + + it 'returns the model version', :aggregate_failures do + is_expected.to have_gitlab_http_status(:ok) + + expect(json_response['model_version']['name']).to eq(model_name) + expect(json_response['model_version']['aliases']).to eq([version]) + end + + describe 'Error States' do + context 'when has access' do + context 'and model does not exist' do + let(:model_name) { 'foo' } + + it_behaves_like 'MLflow|Not Found - Resource Does Not Exist' + end + + context 'and model version does not exist' do + let(:version) { '1.0.1' } + + it_behaves_like 'MLflow|Not Found - Resource Does Not Exist' + end + + context 'and name is not passed' do + let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/registered-models/alias" } + + it_behaves_like 'MLflow|Not Found - Resource Does Not Exist' + end + + context 'and alias is not passed' do + let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/registered-models/alias?name=#{model_name}" } + + it_behaves_like 'MLflow|Not Found - Resource Does Not Exist' + end + end + + it_behaves_like 'MLflow|an authenticated resource' + it_behaves_like 'MLflow|a read-only model registry resource' + end + end + describe 'POST /projects/:id/ml/mlflow/api/2.0/mlflow/registered-models/create' do let(:route) do "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/registered-models/create" diff --git a/spec/requests/projects/registry/repositories_controller_spec.rb b/spec/requests/projects/registry/repositories_controller_spec.rb index 15adb7398f3..ec14834be5b 100644 --- a/spec/requests/projects/registry/repositories_controller_spec.rb +++ b/spec/requests/projects/registry/repositories_controller_spec.rb @@ -21,6 +21,8 @@ RSpec.describe Projects::Registry::RepositoriesController, feature_category: :co end it { is_expected.to have_gitlab_http_status(:ok) } + + it_behaves_like 'pushed feature flag', :container_registry_protected_tags end describe 'GET #show' do @@ -32,5 +34,7 @@ RSpec.describe Projects::Registry::RepositoriesController, feature_category: :co end it { is_expected.to have_gitlab_http_status(:ok) } + + it_behaves_like 'pushed feature flag', :container_registry_protected_tags end end diff --git a/spec/requests/projects/settings/packages_and_registries_controller_spec.rb b/spec/requests/projects/settings/packages_and_registries_controller_spec.rb index 74ab2c54848..6784b3a6861 100644 --- a/spec/requests/projects/settings/packages_and_registries_controller_spec.rb +++ b/spec/requests/projects/settings/packages_and_registries_controller_spec.rb @@ -16,28 +16,6 @@ RSpec.describe Projects::Settings::PackagesAndRegistriesController, feature_cate stub_container_registry_config(enabled: container_registry_enabled) end - shared_examples 'pushed feature flag' do |feature_flag_name| - let(:feature_flag_name_camelized) { feature_flag_name.to_s.camelize(:lower).to_sym } - - it "pushes feature flag :#{feature_flag_name} to the view" do - subject - - expect(response.body).to have_pushed_frontend_feature_flags(feature_flag_name_camelized => true) - end - - context "when feature flag :#{feature_flag_name} is disabled" do - before do - stub_feature_flags(feature_flag_name.to_sym => false) - end - - it "does not push feature flag :#{feature_flag_name} to the view" do - subject - - expect(response.body).to have_pushed_frontend_feature_flags(feature_flag_name_camelized => false) - end - end - end - describe 'GET #show' do context 'when user is authorized' do let(:user) { project.creator } diff --git a/spec/support/shared_examples/requests/feature_flags_shared_examples.rb b/spec/support/shared_examples/requests/feature_flags_shared_examples.rb new file mode 100644 index 00000000000..7c303954ae9 --- /dev/null +++ b/spec/support/shared_examples/requests/feature_flags_shared_examples.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'pushed feature flag' do |feature_flag_name| + let(:feature_flag_name_camelized) { feature_flag_name.to_s.camelize(:lower).to_sym } + + it "pushes feature flag :#{feature_flag_name} `true` to the view" do + subject + + expect(response.body).to have_pushed_frontend_feature_flags(feature_flag_name_camelized => true) + end + + context "when feature flag :#{feature_flag_name} is disabled" do + before do + stub_feature_flags(feature_flag_name.to_sym => false) + end + + it "pushes feature flag :#{feature_flag_name} `false` to the view" do + subject + + expect(response.body).to have_pushed_frontend_feature_flags(feature_flag_name_camelized => false) + end + end +end diff --git a/storybook/config/main.js b/storybook/config/main.js index ebb855792a7..87bb80a8807 100644 --- a/storybook/config/main.js +++ b/storybook/config/main.js @@ -1,3 +1,4 @@ +const path = require('path'); const IS_EE = require('../../config/helpers/is_ee_env'); module.exports = { @@ -15,4 +16,10 @@ module.exports = { docs: { autodocs: true, }, + staticDirs: [ + { + from: path.resolve(__dirname, '../../app/assets/images'), + to: '/assets/images', + }, + ], }; diff --git a/storybook/config/webpack.config.js b/storybook/config/webpack.config.js index 9a1087087c0..44ef81c277a 100644 --- a/storybook/config/webpack.config.js +++ b/storybook/config/webpack.config.js @@ -96,21 +96,36 @@ function sassSmartImporter(url, prev) { return null; } +/** + * Custom function to check if file exists in assets path. + * @param {sass.types.String} url - The value of the Sass variable. + * @returns {sass.types.String} - The path to the asset. + */ +function checkAssetUrl(url) { + const urlString = url.getValue(); + const filePath = path.resolve(__dirname, '../../app/assets/images', urlString); + + // Return as is if it's a data URL. + if (urlString.startsWith('data:')) { + return new sass.types.String(`url('${urlString}')`); + } + + // If the file exists, return the absolute file path. + if (existsSync(filePath)) { + return new sass.types.String(`url('/assets/images/${urlString}')`); + } + + // Otherwise, return the placeholder. + return new sass.types.String(TRANSPARENT_1X1_PNG); +} + const sassLoaderOptions = { sassOptions: { functions: { - 'image-url($url)': function sassImageUrlStub() { - return new sass.types.String(TRANSPARENT_1X1_PNG); - }, - 'asset_path($url)': function sassAssetPathStub() { - return new sass.types.String(TRANSPARENT_1X1_PNG); - }, - 'asset_url($url)': function sassAssetUrlStub() { - return new sass.types.String(TRANSPARENT_1X1_PNG); - }, - 'url($url)': function sassUrlStub() { - return new sass.types.String(TRANSPARENT_1X1_PNG); - }, + 'image-url($url)': checkAssetUrl, + 'asset_path($url)': checkAssetUrl, + 'asset_url($url)': checkAssetUrl, + 'url($url)': checkAssetUrl, }, includePaths: SASS_INCLUDE_PATHS, importer: sassSmartImporter,