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 }}
-
-
-
- {{ kasCheckVersion }}
-
-
- {{ $options.i18n.viewDocsText }}
-
-
-
+
{{ $options.i18n.versionMismatchText }}
-
-
-
- {{ kasCheckVersion }}
-
+
+
+ {{ 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') }}
-
-
-
+
+
+
+
{{ s__('FeatureFlags|Feature flags') }}
-
+
{{ countBadgeContents }}
-
-
+
+
{{ s__('FeatureFlags|View user lists') }}
@@ -236,7 +237,6 @@ export default {
variant="confirm"
category="secondary"
data-testid="ff-configure-button"
- class="gl-mb-0 gl-mr-3"
>
{{ s__('FeatureFlags|Configure') }}
@@ -249,9 +249,10 @@ export default {
data-testid="ff-new-button"
>
{{ 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,