diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-01-18 18:10:42 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-01-18 18:10:42 +0300 |
commit | bfc7eec58ee891178d2a81c424f6c1de22feae5f (patch) | |
tree | 7cf9e2f76befc383e99d28d397ecec4bb9e8d2c9 | |
parent | f23a9a17ed6237c346d2e9210c6841e319e8d030 (diff) |
Add latest changes from gitlab-org/gitlab@master
72 files changed, 1111 insertions, 471 deletions
diff --git a/app/assets/javascripts/boards/components/board_card_layout.vue b/app/assets/javascripts/boards/components/board_card_layout.vue index 8b0265237ba..0a2301394c1 100644 --- a/app/assets/javascripts/boards/components/board_card_layout.vue +++ b/app/assets/javascripts/boards/components/board_card_layout.vue @@ -1,13 +1,17 @@ <script> +import { mapActions, mapGetters } from 'vuex'; import IssueCardInner from './issue_card_inner.vue'; import IssueCardInnerDeprecated from './issue_card_inner_deprecated.vue'; import boardsStore from '../stores/boards_store'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { ISSUABLE } from '~/boards/constants'; export default { name: 'BoardCardLayout', components: { IssueCardInner: gon.features?.graphqlBoardLists ? IssueCardInner : IssueCardInnerDeprecated, }, + mixins: [glFeatureFlagMixin()], props: { list: { type: Object, @@ -42,11 +46,13 @@ export default { }; }, computed: { + ...mapGetters(['isSwimlanesOn']), multiSelectVisible() { return this.multiSelect.list.findIndex((issue) => issue.id === this.issue.id) > -1; }, }, methods: { + ...mapActions(['setActiveId']), mouseDown() { this.showDetail = true; }, @@ -57,6 +63,11 @@ export default { // Don't do anything if this happened on a no trigger element if (e.target.classList.contains('js-no-trigger')) return; + if (this.glFeatures.graphqlBoardLists || this.isSwimlanesOn) { + this.setActiveId({ id: this.issue.id, sidebarType: ISSUABLE }); + return; + } + const isMultiSelect = e.ctrlKey || e.metaKey; if (this.showDetail || isMultiSelect) { diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 2d62dc0fa4b..19254343208 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -109,14 +109,14 @@ export default { /> </component> - <template v-else> - <epics-swimlanes - ref="swimlanes" - :lists="boardListsToUse" - :can-admin-list="canAdminList" - :disabled="disabled" - /> - <board-content-sidebar /> - </template> + <epics-swimlanes + v-else + ref="swimlanes" + :lists="boardListsToUse" + :can-admin-list="canAdminList" + :disabled="disabled" + /> + + <board-content-sidebar v-if="isSwimlanesOn || glFeatures.graphqlBoardLists" /> </div> </template> diff --git a/app/assets/javascripts/environments/components/canary_deployment_callout.vue b/app/assets/javascripts/environments/components/canary_deployment_callout.vue deleted file mode 100644 index a5c0d78524b..00000000000 --- a/app/assets/javascripts/environments/components/canary_deployment_callout.vue +++ /dev/null @@ -1,68 +0,0 @@ -<script> -import { GlButton, GlLink, GlIcon } from '@gitlab/ui'; -import PersistentUserCallout from '~/persistent_user_callout'; - -export default { - components: { - GlButton, - GlLink, - GlIcon, - }, - props: { - canaryDeploymentFeatureId: { - type: String, - required: true, - }, - userCalloutsPath: { - type: String, - required: true, - }, - lockPromotionSvgPath: { - type: String, - required: true, - }, - helpCanaryDeploymentsPath: { - type: String, - required: true, - }, - }, - mounted() { - const callout = this.$refs['canary-deployment-callout']; - PersistentUserCallout.factory(callout); - }, -}; -</script> - -<template> - <div - ref="canary-deployment-callout" - class="p-3 canary-deployment-callout" - :data-dismiss-endpoint="userCalloutsPath" - :data-feature-id="canaryDeploymentFeatureId" - > - <img class="canary-deployment-callout-lock pr-3" :src="lockPromotionSvgPath" /> - - <div class="pl-3"> - <p class="font-weight-bold mb-1"> - {{ __('Upgrade plan to unlock Canary Deployments feature') }} - </p> - - <p class="canary-deployment-callout-message"> - {{ - __( - 'Canary Deployments is a popular CI strategy, where a small portion of the fleet is updated to the new version of your application.', - ) - }} - <gl-link :href="helpCanaryDeploymentsPath">{{ __('Read more') }}</gl-link> - </p> - - <gl-button href="https://about.gitlab.com/sales/" category="secondary" variant="info">{{ - __('Contact sales to upgrade') - }}</gl-button> - </div> - - <div class="ml-auto pr-2 canary-deployment-callout-close js-close"> - <gl-icon name="close" /> - </div> - </div> -</template> diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue index e7697f14802..c6b34fecbb7 100644 --- a/app/assets/javascripts/environments/components/container.vue +++ b/app/assets/javascripts/environments/components/container.vue @@ -10,11 +10,6 @@ export default { GlLoadingIcon, }, props: { - canaryDeploymentFeatureId: { - type: String, - required: false, - default: null, - }, isLoading: { type: Boolean, required: true, @@ -46,11 +41,6 @@ export default { required: false, default: '', }, - showCanaryDeploymentCallout: { - type: Boolean, - required: false, - default: false, - }, userCalloutsPath: { type: String, required: false, @@ -75,8 +65,6 @@ export default { <environment-table :environments="environments" :can-read-environment="canReadEnvironment" - :canary-deployment-feature-id="canaryDeploymentFeatureId" - :show-canary-deployment-callout="showCanaryDeploymentCallout" :user-callouts-path="userCalloutsPath" :lock-promotion-svg-path="lockPromotionSvgPath" :help-canary-deployments-path="helpCanaryDeploymentsPath" diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index 13228873f44..6f68c6e864a 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -39,11 +39,6 @@ export default { type: String, required: true, }, - canaryDeploymentFeatureId: { - type: String, - required: false, - default: '', - }, canCreateEnvironment: { type: Boolean, required: true, @@ -75,11 +70,6 @@ export default { required: false, default: '', }, - showCanaryDeploymentCallout: { - type: Boolean, - required: false, - default: false, - }, userCalloutsPath: { type: String, required: false, @@ -205,8 +195,6 @@ export default { :environments="state.environments" :pagination="state.paginationInformation" :can-read-environment="canReadEnvironment" - :canary-deployment-feature-id="canaryDeploymentFeatureId" - :show-canary-deployment-callout="showCanaryDeploymentCallout" :user-callouts-path="userCalloutsPath" :lock-promotion-svg-path="lockPromotionSvgPath" :help-canary-deployments-path="helpCanaryDeploymentsPath" diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index ff183e51395..bbb56ca6f26 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -8,14 +8,12 @@ import { s__ } from '~/locale'; import EnvironmentItem from './environment_item.vue'; import DeployBoard from './deploy_board.vue'; import CanaryUpdateModal from './canary_update_modal.vue'; -import CanaryDeploymentCallout from './canary_deployment_callout.vue'; export default { components: { EnvironmentItem, GlLoadingIcon, DeployBoard, - CanaryDeploymentCallout, EnvironmentAlert: () => import('ee_component/environments/components/environment_alert.vue'), CanaryUpdateModal, }, @@ -35,11 +33,6 @@ export default { required: false, default: false, }, - canaryDeploymentFeatureId: { - type: String, - required: false, - default: '', - }, helpCanaryDeploymentsPath: { type: String, required: false, @@ -50,11 +43,6 @@ export default { required: false, default: '', }, - showCanaryDeploymentCallout: { - type: Boolean, - required: false, - default: false, - }, userCalloutsPath: { type: String, required: false, @@ -123,9 +111,6 @@ export default { shouldRenderFolderContent(env) { return env.isFolder && env.isOpen && env.children && env.children.length > 0; }, - shouldShowCanaryCallout(env) { - return env.showCanaryCallout && this.showCanaryDeploymentCallout; - }, shouldRenderAlert(env) { return env?.has_opened_alert; }, @@ -243,17 +228,6 @@ export default { </div> </template> </template> - - <template v-if="shouldShowCanaryCallout(model)"> - <canary-deployment-callout - :key="`canary-promo-${i}`" - :canary-deployment-feature-id="canaryDeploymentFeatureId" - :user-callouts-path="userCalloutsPath" - :lock-promotion-svg-path="lockPromotionSvgPath" - :help-canary-deployments-path="helpCanaryDeploymentsPath" - :data-js-canary-promo-key="i" - /> - </template> </template> </div> </template> diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js index 6c547c3713a..e4726412f99 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js +++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js @@ -1,6 +1,5 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import canaryCalloutMixin from '../mixins/canary_callout_mixin'; import environmentsFolderApp from './environments_folder_view.vue'; import { parseBoolean } from '../../lib/utils/common_utils'; import Translate from '../../vue_shared/translate'; @@ -21,7 +20,6 @@ export default () => { components: { environmentsFolderApp, }, - mixins: [canaryCalloutMixin], apolloProvider, provide: { projectPath: el.dataset.projectPath, @@ -43,7 +41,6 @@ export default () => { folderName: this.folderName, cssContainerClass: this.cssContainerClass, canReadEnvironment: this.canReadEnvironment, - ...this.canaryCalloutProps, }, }); }, diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue index 25f5483c58b..dbb60fa4622 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.vue +++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue @@ -34,16 +34,6 @@ export default { type: Boolean, required: true, }, - canaryDeploymentFeatureId: { - type: String, - required: false, - default: '', - }, - showCanaryDeploymentCallout: { - type: Boolean, - required: false, - default: false, - }, userCalloutsPath: { type: String, required: false, @@ -98,8 +88,6 @@ export default { :environments="state.environments" :pagination="state.paginationInformation" :can-read-environment="canReadEnvironment" - :canary-deployment-feature-id="canaryDeploymentFeatureId" - :show-canary-deployment-callout="showCanaryDeploymentCallout" :user-callouts-path="userCalloutsPath" :lock-promotion-svg-path="lockPromotionSvgPath" :help-canary-deployments-path="helpCanaryDeploymentsPath" diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js index 8e8af3f32f7..4d734a457ab 100644 --- a/app/assets/javascripts/environments/index.js +++ b/app/assets/javascripts/environments/index.js @@ -1,6 +1,5 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import canaryCalloutMixin from './mixins/canary_callout_mixin'; import environmentsComponent from './components/environments_app.vue'; import { parseBoolean } from '../lib/utils/common_utils'; import Translate from '../vue_shared/translate'; @@ -20,7 +19,6 @@ export default () => { components: { environmentsComponent, }, - mixins: [canaryCalloutMixin], apolloProvider, provide: { projectPath: el.dataset.projectPath, @@ -46,7 +44,6 @@ export default () => { deployBoardsHelpPath: this.deployBoardsHelpPath, canCreateEnvironment: this.canCreateEnvironment, canReadEnvironment: this.canReadEnvironment, - ...this.canaryCalloutProps, }, }); }, diff --git a/app/assets/javascripts/environments/mixins/canary_callout_mixin.js b/app/assets/javascripts/environments/mixins/canary_callout_mixin.js deleted file mode 100644 index e9f1a144cb3..00000000000 --- a/app/assets/javascripts/environments/mixins/canary_callout_mixin.js +++ /dev/null @@ -1,26 +0,0 @@ -import { parseBoolean } from '~/lib/utils/common_utils'; - -export default { - data() { - const data = this.$options.el.dataset; - - return { - canaryDeploymentFeatureId: data.environmentsDataCanaryDeploymentFeatureId, - showCanaryDeploymentCallout: parseBoolean(data.environmentsDataShowCanaryDeploymentCallout), - userCalloutsPath: data.environmentsDataUserCalloutsPath, - lockPromotionSvgPath: data.environmentsDataLockPromotionSvgPath, - helpCanaryDeploymentsPath: data.environmentsDataHelpCanaryDeploymentsPath, - }; - }, - computed: { - canaryCalloutProps() { - return { - canaryDeploymentFeatureId: this.canaryDeploymentFeatureId, - showCanaryDeploymentCallout: this.showCanaryDeploymentCallout, - userCalloutsPath: this.userCalloutsPath, - lockPromotionSvgPath: this.lockPromotionSvgPath, - helpCanaryDeploymentsPath: this.helpCanaryDeploymentsPath, - }; - }, - }, -}; diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js index 2f4f53953f6..8911885e920 100644 --- a/app/assets/javascripts/environments/stores/environments_store.js +++ b/app/assets/javascripts/environments/stores/environments_store.js @@ -81,17 +81,6 @@ export default class EnvironmentsStore { this.state.environments = filteredEnvironments; - /** - * Add the canary callout banner underneath the second environment listed. - * - * If there is only one environment, then add to it underneath the first. - */ - if (this.state.environments.length >= 2) { - this.state.environments[1].showCanaryCallout = true; - } else if (this.state.environments.length === 1) { - this.state.environments[0].showCanaryCallout = true; - } - return filteredEnvironments; } diff --git a/app/assets/javascripts/jira_connect/api.js b/app/assets/javascripts/jira_connect/api.js index 55f2ef4f820..d689a2d1962 100644 --- a/app/assets/javascripts/jira_connect/api.js +++ b/app/assets/javascripts/jira_connect/api.js @@ -22,3 +22,12 @@ export const removeSubscription = async (removePath) => { }, }); }; + +export const fetchGroups = async (groupsPath, { page, perPage }) => { + return axios.get(groupsPath, { + params: { + page, + per_page: perPage, + }, + }); +}; diff --git a/app/assets/javascripts/jira_connect/components/app.vue b/app/assets/javascripts/jira_connect/components/app.vue index e08918a6720..f5bf30f4488 100644 --- a/app/assets/javascripts/jira_connect/components/app.vue +++ b/app/assets/javascripts/jira_connect/components/app.vue @@ -1,20 +1,33 @@ <script> import { mapState } from 'vuex'; -import { GlAlert } from '@gitlab/ui'; +import { GlAlert, GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import GroupsList from './groups_list.vue'; export default { name: 'JiraConnectApp', components: { GlAlert, + GlButton, + GlModal, + GroupsList, + }, + directives: { + GlModalDirective, }, mixins: [glFeatureFlagsMixin()], computed: { ...mapState(['errorMessage']), - showNewUi() { + showNewUI() { return this.glFeatures.newJiraConnectUi; }, }, + modal: { + cancelProps: { + text: __('Cancel'), + }, + }, }; </script> @@ -26,8 +39,25 @@ export default { <h1>GitLab for Jira Configuration</h1> - <div v-if="showNewUi"> + <div + v-if="showNewUI" + class="gl-display-flex gl-justify-content-space-between gl-my-5 gl-pb-4 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200" + > <h3 data-testid="new-jira-connect-ui-heading">{{ s__('Integrations|Linked namespaces') }}</h3> + <gl-button + v-gl-modal-directive="'add-namespace-modal'" + category="primary" + variant="info" + class="gl-align-self-center" + >{{ s__('Integrations|Add namespace') }}</gl-button + > + <gl-modal + modal-id="add-namespace-modal" + :title="s__('Integrations|Link namespaces')" + :action-cancel="$options.modal.cancelProps" + > + <groups-list /> + </gl-modal> </div> </div> </template> diff --git a/app/assets/javascripts/jira_connect/components/groups_list.vue b/app/assets/javascripts/jira_connect/components/groups_list.vue new file mode 100644 index 00000000000..eeddd32addc --- /dev/null +++ b/app/assets/javascripts/jira_connect/components/groups_list.vue @@ -0,0 +1,88 @@ +<script> +import { GlTabs, GlTab, GlLoadingIcon, GlPagination } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import { fetchGroups } from '~/jira_connect/api'; +import { defaultPerPage } from '~/jira_connect/constants'; +import GroupsListItem from './groups_list_item.vue'; + +export default { + components: { + GlTabs, + GlTab, + GlLoadingIcon, + GlPagination, + GroupsListItem, + }, + inject: { + groupsPath: { + default: '', + }, + }, + data() { + return { + groups: [], + isLoading: false, + page: 1, + perPage: defaultPerPage, + totalItems: 0, + }; + }, + mounted() { + this.loadGroups(); + }, + methods: { + loadGroups() { + this.isLoading = true; + + fetchGroups(this.groupsPath, { + page: this.page, + perPage: this.perPage, + }) + .then((response) => { + const { page, total } = parseIntPagination(normalizeHeaders(response.headers)); + this.page = page; + this.totalItems = total; + this.groups = response.data; + }) + .catch(() => { + // eslint-disable-next-line no-alert + alert(s__('Integrations|Failed to load namespaces. Please try again.')); + }) + .finally(() => { + this.isLoading = false; + }); + }, + }, +}; +</script> + +<template> + <gl-tabs> + <gl-tab :title="__('Groups and subgroups')" class="gl-pt-3"> + <gl-loading-icon v-if="isLoading" size="md" /> + <div v-else-if="groups.length === 0" class="gl-text-center"> + <h5>{{ s__('Integrations|No available namespaces.') }}</h5> + <p class="gl-mt-5"> + {{ + s__('Integrations|You must have owner or maintainer permissions to link namespaces.') + }} + </p> + </div> + <ul v-else class="gl-list-style-none gl-pl-0"> + <groups-list-item v-for="group in groups" :key="group.id" :group="group" /> + </ul> + + <div class="gl-display-flex gl-justify-content-center gl-mt-5"> + <gl-pagination + v-if="totalItems > perPage && groups.length > 0" + v-model="page" + class="gl-mb-0" + :per-page="perPage" + :total-items="totalItems" + @input="loadGroups" + /> + </div> + </gl-tab> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/jira_connect/components/groups_list_item.vue b/app/assets/javascripts/jira_connect/components/groups_list_item.vue new file mode 100644 index 00000000000..15e37ab3cb0 --- /dev/null +++ b/app/assets/javascripts/jira_connect/components/groups_list_item.vue @@ -0,0 +1,42 @@ +<script> +import { GlIcon, GlAvatar } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + GlAvatar, + }, + props: { + group: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <li class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-200"> + <div class="gl-display-flex gl-align-items-center gl-py-3"> + <gl-icon name="folder-o" class="gl-mr-3" /> + <div class="gl-display-none gl-flex-shrink-0 gl-display-sm-flex gl-mr-3"> + <gl-avatar :size="32" shape="rect" :entity-name="group.name" :src="group.avatar_url" /> + </div> + <div class="gl-min-w-0 gl-display-flex gl-flex-grow-1 gl-flex-shrink-1 gl-align-items-center"> + <div class="gl-min-w-0 gl-flex-grow-1 flex-shrink-1"> + <div class="gl-display-flex gl-align-items-center gl-flex-wrap"> + <span + class="gl-mr-3 gl-text-gray-900! gl-font-weight-bold" + data-testid="group-list-item-name" + > + {{ group.full_name }} + </span> + </div> + <div v-if="group.description" data-testid="group-list-item-description"> + <p class="gl-mt-2! gl-mb-0 gl-text-gray-600" v-text="group.description"></p> + </div> + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/jira_connect/constants.js b/app/assets/javascripts/jira_connect/constants.js new file mode 100644 index 00000000000..2b3be5cd5cd --- /dev/null +++ b/app/assets/javascripts/jira_connect/constants.js @@ -0,0 +1 @@ +export const defaultPerPage = 10; diff --git a/app/assets/javascripts/jira_connect/index.js b/app/assets/javascripts/jira_connect/index.js index 0dbcb778a6c..dc2a77f4e0c 100644 --- a/app/assets/javascripts/jira_connect/index.js +++ b/app/assets/javascripts/jira_connect/index.js @@ -73,11 +73,16 @@ function initJiraConnect() { Vue.use(Translate); Vue.use(GlFeatureFlagsPlugin); + const { groupsPath } = el.dataset; + return new Vue({ el, store, + provide: { + groupsPath, + }, render(createElement) { - return createElement(JiraConnectApp, {}); + return createElement(JiraConnectApp); }, }); } diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 2d40f60a758..15f7c0c874e 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -4,6 +4,8 @@ import * as timeago from 'timeago.js'; import dateFormat from 'dateformat'; import { languageCode, s__, __, n__ } from '../../locale'; +const MILLISECONDS_IN_DAY = 24 * 60 * 60 * 1000; + window.timeago = timeago; /** @@ -851,3 +853,45 @@ export const format24HourTimeStringFromInt = (time) => { const formatted24HourString = time > 9 ? `${time}:00` : `0${time}:00`; return formatted24HourString; }; + +/** + * A utility function which checks if two date ranges overlap. + * + * @param {Object} givenPeriodLeft - the first period to compare. + * @param {Object} givenPeriodRight - the second period to compare. + * @returns {Object} { overlap: number of days the overlap is present, overlapStartDate: the start date of the overlap in time format, overlapEndDate: the end date of the overlap in time format } + * @throws {Error} Uncaught Error: Invalid period + * + * @example + * getOverlappingDaysInPeriods( + * { start: new Date(2021, 0, 11), end: new Date(2021, 0, 13) }, + * { start: new Date(2021, 0, 11), end: new Date(2021, 0, 14) } + * ) => { daysOverlap: 2, overlapStartDate: 1610323200000, overlapEndDate: 1610496000000 } + * + */ +export const getOverlappingDaysInPeriods = (givenPeriodLeft = {}, givenPeriodRight = {}) => { + const leftStartTime = new Date(givenPeriodLeft.start).getTime(); + const leftEndTime = new Date(givenPeriodLeft.end).getTime(); + const rightStartTime = new Date(givenPeriodRight.start).getTime(); + const rightEndTime = new Date(givenPeriodRight.end).getTime(); + + if (!(leftStartTime <= leftEndTime && rightStartTime <= rightEndTime)) { + throw new Error(__('Invalid period')); + } + + const isOverlapping = leftStartTime < rightEndTime && rightStartTime < leftEndTime; + + if (!isOverlapping) { + return { daysOverlap: 0 }; + } + + const overlapStartDate = Math.max(leftStartTime, rightStartTime); + const overlapEndDate = rightEndTime > leftEndTime ? leftEndTime : rightEndTime; + const differenceInMs = overlapEndDate - overlapStartDate; + + return { + daysOverlap: Math.ceil(differenceInMs / MILLISECONDS_IN_DAY), + overlapStartDate, + overlapEndDate, + }; +}; diff --git a/app/assets/stylesheets/page_bundles/environments.scss b/app/assets/stylesheets/page_bundles/environments.scss index 470660adba9..7d5f501d633 100644 --- a/app/assets/stylesheets/page_bundles/environments.scss +++ b/app/assets/stylesheets/page_bundles/environments.scss @@ -218,57 +218,3 @@ padding-right: 10px; } } - -.canary-deployment-callout { - border-bottom: 1px solid var(--gray-500, $gray-500); - display: flex; - - @include media-breakpoint-down(sm) { - display: none; - } - - &-lock { - height: 82px; - width: 92px; - } - - &-message { - max-width: 600px; - color: var(--gray-500, $gray-500); - } - - &-close { - color: var(--gray-500, $gray-500); - cursor: pointer; - } - - &-button { - border-color: var(--blue-500, $blue-500); - color: var(--blue-500, $blue-500); - - &:not(:disabled):not(.disabled):active { - background-color: var(--blue-200, $blue-200); - border: 2px solid var(--blue-600, $blue-600); - color: var(--blue-700, $blue-700); - height: 34px; - padding: 5px 9px; - } - - &:focus { - background-color: var(--blue-500, $blue-500); - border: 2px solid var(--blue-500, $blue-500); - box-shadow: 0 0 4px 1px var(--blue-200, $blue-200); - color: var(--blue-600, $blue-600); - height: 34px; - padding: 5px 9px; - } - - &:hover { - background-color: var(--blue-500, $blue-500); - border: 2px solid var(--blue-500, $blue-500); - color: var(--blue-600, $blue-600); - height: 34px; - padding: 5px 9px; - } - } -} diff --git a/app/assets/stylesheets/page_bundles/jira_connect.scss b/app/assets/stylesheets/page_bundles/jira_connect.scss index 81e9b04b18e..231723ca4e3 100644 --- a/app/assets/stylesheets/page_bundles/jira_connect.scss +++ b/app/assets/stylesheets/page_bundles/jira_connect.scss @@ -1,15 +1,24 @@ @import 'mixins_and_variables_and_functions'; -/** -NOTE: We should only import styles that we actually use. -Ex: - @import '@gitlab/ui/src/scss/gitlab_ui'; -*/ + @import '@gitlab/ui/src/scss/bootstrap'; @import 'bootstrap-vue/src/index'; @import '@gitlab/ui/src/scss/utilities'; @import '@gitlab/ui/src/components/base/alert/alert'; +// We should only import styles that we actually use. +@import '@gitlab/ui/src/components/base/alert/alert'; +@import '@gitlab/ui/src/components/base/avatar/avatar'; +@import '@gitlab/ui/src/components/base/badge/badge'; +@import '@gitlab/ui/src/components/base/button/button'; +@import '@gitlab/ui/src/components/base/icon/icon'; +@import '@gitlab/ui/src/components/base/link/link'; +@import '@gitlab/ui/src/components/base/loading_icon/loading_icon'; +@import '@gitlab/ui/src/components/base/modal/modal'; +@import '@gitlab/ui/src/components/base/pagination/pagination'; +@import '@gitlab/ui/src/components/base/tabs/tabs/tabs'; +@import '@gitlab/ui/src/components/base/tooltip/tooltip'; + $atlaskit-border-color: #dfe1e6; .ac-content { diff --git a/app/assets/stylesheets/page_bundles/oncall_schedules.scss b/app/assets/stylesheets/page_bundles/oncall_schedules.scss index 2ab3bdcc474..1b190024457 100644 --- a/app/assets/stylesheets/page_bundles/oncall_schedules.scss +++ b/app/assets/stylesheets/page_bundles/oncall_schedules.scss @@ -113,8 +113,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi } .item-label { - @include gl-py-4; - @include gl-pl-4; border-right: $border-style; border-bottom: $border-style; } diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb index f4e05e19eca..84b61340e93 100644 --- a/app/graphql/types/notes/note_type.rb +++ b/app/graphql/types/notes/note_type.rb @@ -46,6 +46,13 @@ module Types field :confidential, GraphQL::BOOLEAN_TYPE, null: true, description: 'Indicates if this note is confidential', method: :confidential? + field :url, GraphQL::STRING_TYPE, + null: true, + description: 'URL to view this Note in the Web UI' + + def url + ::Gitlab::UrlBuilder.build(object) + end def system_note_icon_name SystemNoteHelper.system_note_icon_name(object) if object.system? diff --git a/app/helpers/jira_connect_helper.rb b/app/helpers/jira_connect_helper.rb index c30eb1b007a..f1527b9b85a 100644 --- a/app/helpers/jira_connect_helper.rb +++ b/app/helpers/jira_connect_helper.rb @@ -4,4 +4,10 @@ module JiraConnectHelper def new_jira_connect_ui? Feature.enabled?(:new_jira_connect_ui, type: :development, default_enabled: :yaml) end + + def jira_connect_app_data + { + groups_path: api_v4_groups_path(params: { min_access_level: Gitlab::Access::MAINTAINER }) + } + end end diff --git a/app/services/jira_connect/sync_service.rb b/app/services/jira_connect/sync_service.rb index b2af284f1f0..bddc7cbe5a0 100644 --- a/app/services/jira_connect/sync_service.rb +++ b/app/services/jira_connect/sync_service.rb @@ -31,7 +31,7 @@ module JiraConnect jira_response: response&.to_json } - if response && (response['errorMessages'] || response['rejectedBuilds'].present?) + if response && response['errorMessages'].present? logger.error(message) else logger.info(message) diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index 53872c67f49..25d46ada885 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -37,16 +37,11 @@ module Projects raise InvalidStateError, 'missing pages artifacts' unless build.artifacts? raise InvalidStateError, 'build SHA is outdated for this ref' unless latest? - # Create temporary directory in which we will extract the artifacts - make_secure_tmp_dir(tmp_path) do |archive_path| - extract_archive!(archive_path) + build.artifacts_file.use_file do |artifacts_path| + deploy_to_legacy_storage(artifacts_path) - # Check if we did extract public directory - archive_public_path = File.join(archive_path, PUBLIC_DIR) - raise InvalidStateError, 'pages miss the public folder' unless Dir.exist?(archive_public_path) - raise InvalidStateError, 'build SHA is outdated for this ref' unless latest? + create_pages_deployment(artifacts_path, build) - deploy_page!(archive_public_path) success end rescue InvalidStateError => e @@ -84,15 +79,29 @@ module Projects ) end - def extract_archive!(temp_path) + def deploy_to_legacy_storage(artifacts_path) + # Create temporary directory in which we will extract the artifacts + make_secure_tmp_dir(tmp_path) do |tmp_path| + extract_archive!(artifacts_path, tmp_path) + + # Check if we did extract public directory + archive_public_path = File.join(tmp_path, PUBLIC_DIR) + raise InvalidStateError, 'pages miss the public folder' unless Dir.exist?(archive_public_path) + raise InvalidStateError, 'build SHA is outdated for this ref' unless latest? + + deploy_page!(archive_public_path) + end + end + + def extract_archive!(artifacts_path, temp_path) if artifacts.ends_with?('.zip') - extract_zip_archive!(temp_path) + extract_zip_archive!(artifacts_path, temp_path) else raise InvalidStateError, 'unsupported artifacts format' end end - def extract_zip_archive!(temp_path) + def extract_zip_archive!(artifacts_path, temp_path) raise InvalidStateError, 'missing artifacts metadata' unless build.artifacts_metadata? # Calculate page size after extract @@ -102,11 +111,8 @@ module Projects raise InvalidStateError, "artifacts for pages are too large: #{public_entry.total_size}" end - build.artifacts_file.use_file do |artifacts_path| - SafeZip::Extract.new(artifacts_path) - .extract(directories: [PUBLIC_DIR], to: temp_path) - create_pages_deployment(artifacts_path, build) - end + SafeZip::Extract.new(artifacts_path) + .extract(directories: [PUBLIC_DIR], to: temp_path) rescue SafeZip::Extract::Error => e raise FailedToExtractError, e.message end @@ -150,6 +156,9 @@ module Projects deployment = project.pages_deployments.create!(file: file, file_count: entries_count, file_sha256: sha256) + + raise InvalidStateError, 'build SHA is outdated for this ref' unless latest? + project.update_pages_deployment!(deployment) end diff --git a/app/views/jira_connect/subscriptions/index.html.haml b/app/views/jira_connect/subscriptions/index.html.haml index 30dad19be37..ed765f80b74 100644 --- a/app/views/jira_connect/subscriptions/index.html.haml +++ b/app/views/jira_connect/subscriptions/index.html.haml @@ -20,7 +20,7 @@ .gl-mt-5 %p Note: this integration only works with accounts on GitLab.com (SaaS). - else - .js-jira-connect-app + .js-jira-connect-app{ data: jira_connect_app_data } - unless new_jira_connect_ui? %form#add-subscription-form.subscription-form{ action: jira_connect_subscriptions_path } diff --git a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb index 64e65958113..7c86b194574 100644 --- a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb +++ b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb @@ -109,6 +109,13 @@ module ContainerExpirationPolicies log_extra_metadata_on_done(field, value) end + + before_truncate_size = result.payload[:cleanup_tags_service_before_truncate_size] + after_truncate_size = result.payload[:cleanup_tags_service_after_truncate_size] + truncated = before_truncate_size && + after_truncate_size && + before_truncate_size != after_truncate_size + log_extra_metadata_on_done(:cleanup_tags_service_truncated, !!truncated) end end end diff --git a/changelogs/unreleased/dblessing_redirect_deprecated_profile_paths.yml b/changelogs/unreleased/dblessing_redirect_deprecated_profile_paths.yml new file mode 100644 index 00000000000..1b4f0694e32 --- /dev/null +++ b/changelogs/unreleased/dblessing_redirect_deprecated_profile_paths.yml @@ -0,0 +1,5 @@ +--- +title: Redirect deprecated profile paths to scoped path +merge_request: 51646 +author: +type: changed diff --git a/config/routes.rb b/config/routes.rb index 16e833ede71..91d1a817175 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -279,7 +279,8 @@ Rails.application.routes.draw do # Issue https://gitlab.com/gitlab-org/gitlab/-/issues/210024 scope as: 'deprecated' do draw :snippets - draw :profile + + Gitlab::Routing.redirect_legacy_paths(self, :profile) end Gitlab.ee do diff --git a/doc/administration/incoming_email.md b/doc/administration/incoming_email.md index 7a49542f8bb..2eb5da7d9ab 100644 --- a/doc/administration/incoming_email.md +++ b/doc/administration/incoming_email.md @@ -251,7 +251,7 @@ incoming_email: #### Gmail -Example configuration for Gmail/G Suite. Assumes mailbox `gitlab-incoming@gmail.com`. +Example configuration for Gmail/Google Workspace. Assumes mailbox `gitlab-incoming@gmail.com`. NOTE: `incoming_email_email` cannot be a Gmail alias account. diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 26f0cd705c6..94792a49933 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -16166,6 +16166,11 @@ type Note implements ResolvableInterface { updatedAt: Time! """ + URL to view this Note in the Web UI + """ + url: String + + """ Permissions for the current user on the resource """ userPermissions: NotePermissions! diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 625892f3d97..138530abb17 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -47751,6 +47751,20 @@ "deprecationReason": null }, { + "name": "url", + "description": "URL to view this Note in the Web UI", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "userPermissions", "description": "Permissions for the current user on the resource", "args": [ diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 72c38f67773..c098de16ef6 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -2430,6 +2430,7 @@ Autogenerated return type of NamespaceIncreaseStorageTemporarily. | `system` | Boolean! | Indicates whether this note was created by the system or by a user | | `systemNoteIconName` | String | Name of the icon corresponding to a system note | | `updatedAt` | Time! | Timestamp of the note's last activity | +| `url` | String | URL to view this Note in the Web UI | | `userPermissions` | NotePermissions! | Permissions for the current user on the resource | ### NotePermissions diff --git a/doc/integration/elasticsearch.md b/doc/integration/elasticsearch.md index 93ebad89a96..b1fc2573bb0 100644 --- a/doc/integration/elasticsearch.md +++ b/doc/integration/elasticsearch.md @@ -205,6 +205,12 @@ To enable Advanced Search, you need to have admin access to GitLab: **Admin Area > Settings > General > Advanced Search** and click **Save changes**. +NOTE: +When your Elasticsearch cluster is down while Elasticsearch is enabled, +you might have problems updating documents such as issues because your +instance queues a job to index the change, but cannot find a valid +Elasticsearch cluster. + ### Advanced Search configuration The following Elasticsearch settings are available: diff --git a/doc/user/group/saml_sso/index.md b/doc/user/group/saml_sso/index.md index 8b62f0e297f..0ce92eac1a3 100644 --- a/doc/user/group/saml_sso/index.md +++ b/doc/user/group/saml_sso/index.md @@ -169,7 +169,7 @@ Your identity provider may have relevant documentation. It may be generic SAML d - [ADFS (Active Directory Federation Services)](https://docs.microsoft.com/en-us/windows-server/identity/ad-fs/operations/create-a-relying-party-trust) - [Auth0](https://auth0.com/docs/protocols/saml-configuration-options/configure-auth0-as-saml-identity-provider) -- [G Suite](https://support.google.com/a/answer/6087519?hl=en) +- [Google Workspace](https://support.google.com/a/answer/6087519?hl=en) - [JumpCloud](https://support.jumpcloud.com/support/s/article/single-sign-on-sso-with-gitlab-2019-08-21-10-36-47) - [PingOne by Ping Identity](https://docs.pingidentity.com/bundle/pingone/page/xsh1564020480660-1.html) diff --git a/lib/atlassian/jira_connect/client.rb b/lib/atlassian/jira_connect/client.rb index 5f7550bdbb4..c67fe24d456 100644 --- a/lib/atlassian/jira_connect/client.rb +++ b/lib/atlassian/jira_connect/client.rb @@ -35,33 +35,44 @@ module Atlassian def store_ff_info(project:, feature_flags:, **opts) return unless Feature.enabled?(:jira_sync_feature_flags, project) - items = feature_flags.map { |flag| Serializers::FeatureFlagEntity.represent(flag, opts) } + items = feature_flags.map { |flag| ::Atlassian::JiraConnect::Serializers::FeatureFlagEntity.represent(flag, opts) } items.reject! { |item| item.issue_keys.empty? } return if items.empty? - post('/rest/featureflags/0.1/bulk', { + r = post('/rest/featureflags/0.1/bulk', { flags: items, properties: { projectId: "project-#{project.id}" } }) + + handle_response(r, 'feature flags') do |data| + failed = data['failedFeatureFlags'] + if failed.present? + errors = failed.flat_map do |k, errs| + errs.map { |e| "#{k}: #{e['message']}" } + end + { 'errorMessages' => errors } + end + end end def store_deploy_info(project:, deployments:, **opts) return unless Feature.enabled?(:jira_sync_deployments, project) - items = deployments.map { |d| Serializers::DeploymentEntity.represent(d, opts) } + items = deployments.map { |d| ::Atlassian::JiraConnect::Serializers::DeploymentEntity.represent(d, opts) } items.reject! { |d| d.issue_keys.empty? } return if items.empty? - post('/rest/deployments/0.1/bulk', { deployments: items }) + r = post('/rest/deployments/0.1/bulk', { deployments: items }) + handle_response(r, 'deployments') { |data| errors(data, 'rejectedDeployments') } end def store_build_info(project:, pipelines:, update_sequence_id: nil) return unless Feature.enabled?(:jira_sync_builds, project) builds = pipelines.map do |pipeline| - build = Serializers::BuildEntity.represent( + build = ::Atlassian::JiraConnect::Serializers::BuildEntity.represent( pipeline, update_sequence_id: update_sequence_id ) @@ -71,7 +82,8 @@ module Atlassian end.compact return if builds.empty? - post('/rest/builds/0.1/bulk', { builds: builds }) + r = post('/rest/builds/0.1/bulk', { builds: builds }) + handle_response(r, 'builds') { |data| errors(data, 'rejectedBuilds') } end def store_dev_info(project:, commits: nil, branches: nil, merge_requests: nil, update_sequence_id: nil) @@ -104,6 +116,34 @@ module Atlassian { providerMetadata: { product: "GitLab #{Gitlab::VERSION}" } } end + def handle_response(response, name, &block) + data = response.parsed_response + + case response.code + when 200 then yield data + when 400 then { 'errorMessages' => data.map { |e| e['message'] } } + when 401 then { 'errorMessages' => ['Invalid JWT'] } + when 403 then { 'errorMessages' => ["App does not support #{name}"] } + when 413 then { 'errorMessages' => ['Data too large'] + data.map { |e| e['message'] } } + when 429 then { 'errorMessages' => ['Rate limit exceeded'] } + when 503 then { 'errorMessages' => ['Service unavailable'] } + else + { 'errorMessages' => ['Unknown error'], 'response' => data } + end + end + + def errors(data, key) + messages = if data[key].present? + data[key].flat_map do |rejection| + rejection['errors'].map { |e| e['message'] } + end + else + [] + end + + { 'errorMessages' => messages } + end + def user_notes_count(merge_requests) return unless merge_requests diff --git a/lib/gitlab/experimentation.rb b/lib/gitlab/experimentation.rb index 196203211ed..9fa49cd7c70 100644 --- a/lib/gitlab/experimentation.rb +++ b/lib/gitlab/experimentation.rb @@ -6,6 +6,7 @@ # Experiment options: # - tracking_category (optional, used to set the category when tracking an experiment event) # - use_backwards_compatible_subject_index (optional, set this to true if you need backwards compatibility -- you likely do not need this, see note in the next paragraph.) +# - rollout_strategy: default is `:cookie` based rollout. We may also set it to `:user` based rollout # # Using the backwards-compatible subject index (use_backwards_compatible_subject_index option): # This option was added when [the calculation of experimentation_subject_index was changed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45733/diffs#41af4a6fa5a10c7068559ce21c5188483751d934_157_173). It is not intended to be used by new experiments, it exists merely for the segmentation integrity of in-flight experiments at the time the change was deployed. That is, we want users who were assigned to the "experimental" group or the "control" group before the change to still be in those same groups after the change. See [the original issue](https://gitlab.com/gitlab-org/gitlab/-/issues/270858) and [this related comment](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48110#note_458223745) for more information. @@ -92,16 +93,19 @@ module Gitlab tracking_category: 'Growth::Conversion::Experiment::TrialDuringSignup' }, ci_syntax_templates: { - tracking_category: 'Growth::Activation::Experiment::CiSyntaxTemplates' + tracking_category: 'Growth::Activation::Experiment::CiSyntaxTemplates', + rollout_strategy: :user }, pipelines_empty_state: { - tracking_category: 'Growth::Activation::Experiment::PipelinesEmptyState' + tracking_category: 'Growth::Activation::Experiment::PipelinesEmptyState', + rollout_strategy: :user }, invite_members_new_dropdown: { tracking_category: 'Growth::Expansion::Experiment::InviteMembersNewDropdown' }, show_trial_status_in_sidebar: { - tracking_category: 'Growth::Conversion::Experiment::ShowTrialStatusInSidebar' + tracking_category: 'Growth::Conversion::Experiment::ShowTrialStatusInSidebar', + rollout_strategy: :group }, trial_onboarding_issues: { tracking_category: 'Growth::Conversion::Experiment::TrialOnboardingIssues' @@ -125,6 +129,7 @@ module Gitlab def in_experiment_group?(experiment_key, subject:) return false if subject.blank? return false unless active?(experiment_key) + raise 'Subject must conform to the rollout strategy' unless valid_subject_for_rollout_strategy?(experiment_key, subject) experiment = get_experiment(experiment_key) return false unless experiment @@ -132,6 +137,26 @@ module Gitlab experiment.enabled_for_index?(index_for_subject(experiment, subject)) end + def rollout_strategy(experiment_key) + experiment = get_experiment(experiment_key) + return unless experiment + + experiment.rollout_strategy + end + + def valid_subject_for_rollout_strategy?(experiment_key, subject) + case rollout_strategy(experiment_key) + when :user + subject.is_a?(User) + when :group + subject.is_a?(Group) + when :cookie + subject.nil? || subject.is_a?(String) + else + false + end + end + private def index_for_subject(experiment, subject) diff --git a/lib/gitlab/experimentation/controller_concern.rb b/lib/gitlab/experimentation/controller_concern.rb index e43f3c8c007..e76f33c2250 100644 --- a/lib/gitlab/experimentation/controller_concern.rb +++ b/lib/gitlab/experimentation/controller_concern.rb @@ -39,6 +39,7 @@ module Gitlab def experiment_enabled?(experiment_key, subject: nil) return true if forced_enabled?(experiment_key) return false if dnt_enabled? + raise "Subject must conform to the rollout strategy for #{experiment_key}" unless Experimentation.valid_subject_for_rollout_strategy?(experiment_key, subject) subject ||= fallback_experimentation_subject_index(experiment_key) @@ -65,7 +66,9 @@ module Gitlab return if dnt_enabled? return unless Experimentation.active?(experiment_key) && current_user - ::Experiment.add_user(experiment_key, tracking_group(experiment_key, nil, subject: current_user), current_user, context) + subject = Experimentation.rollout_strategy(experiment_key) == :cookie ? nil : current_user + + ::Experiment.add_user(experiment_key, tracking_group(experiment_key, nil, subject: subject), current_user, context) end def record_experiment_conversion_event(experiment_key) diff --git a/lib/gitlab/experimentation/experiment.rb b/lib/gitlab/experimentation/experiment.rb index 36cd673a38f..17dda45f5b7 100644 --- a/lib/gitlab/experimentation/experiment.rb +++ b/lib/gitlab/experimentation/experiment.rb @@ -5,12 +5,13 @@ module Gitlab class Experiment FEATURE_FLAG_SUFFIX = "_experiment_percentage" - attr_reader :key, :tracking_category, :use_backwards_compatible_subject_index + attr_reader :key, :tracking_category, :use_backwards_compatible_subject_index, :rollout_strategy def initialize(key, **params) @key = key @tracking_category = params[:tracking_category] @use_backwards_compatible_subject_index = params[:use_backwards_compatible_subject_index] + @rollout_strategy = params[:rollout_strategy] || :cookie end def active? diff --git a/lib/gitlab/hashed_storage/rake_helper.rb b/lib/gitlab/hashed_storage/rake_helper.rb index 7965f165683..d3468569e5e 100644 --- a/lib/gitlab/hashed_storage/rake_helper.rb +++ b/lib/gitlab/hashed_storage/rake_helper.rb @@ -65,6 +65,7 @@ module Gitlab def self.projects_list(relation_name, relation) listing(relation_name, relation.with_route) do |project| $stdout.puts " - #{project.full_path} (id: #{project.id})".color(:red) + $stdout.puts " #{project.repository.disk_path}" end end @@ -92,6 +93,37 @@ module Gitlab end end # rubocop: enable CodeReuse/ActiveRecord + + def self.prune(relation_name, relation, dry_run: true, root: nil) + root ||= '../repositories' + + known_paths = Set.new + listing(relation_name, relation) { |p| known_paths << "#{root}/#{p.repository.disk_path}" } + + marked_for_deletion = Set.new(Dir["#{root}/@hashed/*/*/*"]) + marked_for_deletion.reject! do |path| + base = path.gsub(/\.(\w+\.)?git$/, '') + known_paths.include?(base) + end + + if marked_for_deletion.empty? + $stdout.puts "No orphaned directories found. Nothing to do!" + else + n = marked_for_deletion.size + $stdout.puts "Found #{n} orphaned #{'directory'.pluralize(n)}" + $stdout.puts "Dry run. (Run again with FORCE=1 to delete). We would have deleted:" if dry_run + end + + marked_for_deletion.each do |p| + p = Pathname.new(p) + if dry_run + $stdout.puts " - #{p}" + else + $stdout.puts "Removing #{p}" + p.rmtree + end + end + end end end end diff --git a/lib/tasks/gitlab/storage.rake b/lib/tasks/gitlab/storage.rake index ccc96b7edfb..f7819fd974b 100644 --- a/lib/tasks/gitlab/storage.rake +++ b/lib/tasks/gitlab/storage.rake @@ -116,6 +116,21 @@ namespace :gitlab do helper.projects_list('projects using Hashed Storage', Project.with_storage_feature(:repository)) end + desc 'Gitlab | Storage | Prune projects using Hashed Storage. Remove all hashed directories that do not have a project associated' + task prune_hashed_projects: [:environment, :gitlab_environment] do + if Rails.env.production? + abort('This destructive action may only be run in development') + end + + helper = Gitlab::HashedStorage::RakeHelper + name = 'projects using Hashed Storage' + relation = Project.with_storage_feature(:repository) + root = Gitlab.config.repositories.storages['default'].legacy_disk_path + dry_run = !ENV['FORCE'].present? + + helper.prune(name, relation, dry_run: dry_run, root: root) + end + desc 'Gitlab | Storage | Summary of project attachments using Legacy Storage' task legacy_attachments: :environment do helper = Gitlab::HashedStorage::RakeHelper diff --git a/locale/gitlab.pot b/locale/gitlab.pot index af432fcd1a8..74f1f257b20 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5071,9 +5071,6 @@ msgstr "" msgid "Canary" msgstr "" -msgid "Canary Deployments is a popular CI strategy, where a small portion of the fleet is updated to the new version of your application." -msgstr "" - msgid "Canary Ingress does not exist in the environment." msgstr "" @@ -7476,9 +7473,6 @@ msgstr "" msgid "Contact Sales to upgrade" msgstr "" -msgid "Contact sales to upgrade" -msgstr "" - msgid "Contact support" msgstr "" @@ -10860,15 +10854,15 @@ msgstr "" msgid "End Time" msgstr "" -msgid "Ends at %{endsAt}" -msgstr "" - msgid "Ends at (UTC)" msgstr "" msgid "Ends on" msgstr "" +msgid "Ends: %{endsAt}" +msgstr "" + msgid "Enforce DNS rebinding attack protection" msgstr "" @@ -15332,6 +15326,9 @@ msgstr "" msgid "Integrations|%{integration} settings saved, but not active." msgstr "" +msgid "Integrations|Add namespace" +msgstr "" + msgid "Integrations|All details" msgstr "" @@ -15362,6 +15359,9 @@ msgstr "" msgid "Integrations|Enable comments" msgstr "" +msgid "Integrations|Failed to load namespaces. Please try again." +msgstr "" + msgid "Integrations|Includes Standard plus entire commit message, commit hash, and issue IDs" msgstr "" @@ -15371,12 +15371,18 @@ msgstr "" msgid "Integrations|Issues created in Jira are shown here once you have created the issues in project setup in Jira." msgstr "" +msgid "Integrations|Link namespaces" +msgstr "" + msgid "Integrations|Linked namespaces" msgstr "" msgid "Integrations|Namespaces are your GitLab groups and subgroups that will be linked to this Jira instance." msgstr "" +msgid "Integrations|No available namespaces." +msgstr "" + msgid "Integrations|Projects using custom settings will not be affected." msgstr "" @@ -15428,6 +15434,9 @@ msgstr "" msgid "Integrations|You can now close this window and return to the GitLab for Jira application." msgstr "" +msgid "Integrations|You must have owner or maintainer permissions to link namespaces." +msgstr "" + msgid "Interactive mode" msgstr "" @@ -15515,6 +15524,9 @@ msgstr "" msgid "Invalid login or password" msgstr "" +msgid "Invalid period" +msgstr "" + msgid "Invalid pin code" msgstr "" @@ -26926,15 +26938,15 @@ msgstr "" msgid "Starts %{startsIn}" msgstr "" -msgid "Starts at %{startsAt}" -msgstr "" - msgid "Starts at (UTC)" msgstr "" msgid "Starts on" msgstr "" +msgid "Starts: %{startsAt}" +msgstr "" + msgid "State your message to activate" msgstr "" @@ -30536,9 +30548,6 @@ msgstr "" msgid "Updating" msgstr "" -msgid "Upgrade plan to unlock Canary Deployments feature" -msgstr "" - msgid "Upgrade your plan" msgstr "" @@ -32537,6 +32546,9 @@ msgstr "" msgid "You have insufficient permissions to update this HTTP integration" msgstr "" +msgid "You have insufficient permissions to view shifts for this rotation" +msgstr "" + msgid "You have no permissions" msgstr "" diff --git a/package.json b/package.json index d827e12ae77..a5ca4851efa 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@gitlab/favicon-overlay": "2.0.0", "@gitlab/svgs": "1.178.0", "@gitlab/tributejs": "1.0.0", - "@gitlab/ui": "25.11.1", + "@gitlab/ui": "25.11.3", "@gitlab/visual-review-tools": "1.6.1", "@rails/actioncable": "^6.0.3-4", "@rails/ujs": "^6.0.3-4", diff --git a/spec/features/security/dashboard_access_spec.rb b/spec/features/security/dashboard_access_spec.rb index 5ac4a5c1840..5430329d47d 100644 --- a/spec/features/security/dashboard_access_spec.rb +++ b/spec/features/security/dashboard_access_spec.rb @@ -57,7 +57,7 @@ RSpec.describe "Dashboard access" do it { expect(new_group_path).to be_denied_for :visitor } end - describe "GET /profile/groups" do + describe "GET /dashboard/groups" do subject { dashboard_groups_path } it { is_expected.to be_allowed_for :admin } diff --git a/spec/features/security/profile_access_spec.rb b/spec/features/security/profile_access_spec.rb index 3aa8278866c..301efd2d99b 100644 --- a/spec/features/security/profile_access_spec.rb +++ b/spec/features/security/profile_access_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe "Profile access" do include AccessMatchers - describe "GET /profile/keys" do + describe "GET /-/profile/keys" do subject { profile_keys_path } it { is_expected.to be_allowed_for :admin } @@ -13,7 +13,7 @@ RSpec.describe "Profile access" do it { is_expected.to be_denied_for :visitor } end - describe "GET /profile" do + describe "GET /-/profile" do subject { profile_path } it { is_expected.to be_allowed_for :admin } @@ -21,7 +21,7 @@ RSpec.describe "Profile access" do it { is_expected.to be_denied_for :visitor } end - describe "GET /profile/account" do + describe "GET /-/profile/account" do subject { profile_account_path } it { is_expected.to be_allowed_for :admin } @@ -29,7 +29,7 @@ RSpec.describe "Profile access" do it { is_expected.to be_denied_for :visitor } end - describe "GET /profile/preferences" do + describe "GET /-/profile/preferences" do subject { profile_preferences_path } it { is_expected.to be_allowed_for :admin } @@ -37,7 +37,7 @@ RSpec.describe "Profile access" do it { is_expected.to be_denied_for :visitor } end - describe "GET /profile/audit_log" do + describe "GET /-/profile/audit_log" do subject { audit_log_profile_path } it { is_expected.to be_allowed_for :admin } @@ -45,7 +45,7 @@ RSpec.describe "Profile access" do it { is_expected.to be_denied_for :visitor } end - describe "GET /profile/notifications" do + describe "GET /-/profile/notifications" do subject { profile_notifications_path } it { is_expected.to be_allowed_for :admin } diff --git a/spec/frontend/boards/components/board_card_layout_spec.js b/spec/frontend/boards/components/board_card_layout_spec.js index d627f971724..d8633871e8d 100644 --- a/spec/frontend/boards/components/board_card_layout_spec.js +++ b/spec/frontend/boards/components/board_card_layout_spec.js @@ -1,7 +1,8 @@ /* global List */ /* global ListLabel */ -import { shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; @@ -10,20 +11,35 @@ import axios from '~/lib/utils/axios_utils'; import '~/boards/models/label'; import '~/boards/models/assignee'; import '~/boards/models/list'; -import store from '~/boards/stores'; +import boardsVuexStore from '~/boards/stores'; import boardsStore from '~/boards/stores/boards_store'; import BoardCardLayout from '~/boards/components/board_card_layout.vue'; import issueCardInner from '~/boards/components/issue_card_inner.vue'; import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data'; +import { ISSUABLE } from '~/boards/constants'; + describe('Board card layout', () => { let wrapper; let mock; let list; + let store; + + const localVue = createLocalVue(); + localVue.use(Vuex); + + const createStore = ({ getters = {}, actions = {} } = {}) => { + store = new Vuex.Store({ + ...boardsVuexStore, + actions, + getters, + }); + }; // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized - const mountComponent = (propsData) => { + const mountComponent = ({ propsData = {}, provide = {} } = {}) => { wrapper = shallowMount(BoardCardLayout, { + localVue, stubs: { issueCardInner, }, @@ -39,6 +55,7 @@ describe('Board card layout', () => { groupId: null, rootPath: '/', scopedLabelsAvailable: false, + ...provide, }, }); }; @@ -75,6 +92,7 @@ describe('Board card layout', () => { describe('mouse events', () => { it('sets showDetail to true on mousedown', async () => { + createStore(); mountComponent(); wrapper.trigger('mousedown'); @@ -84,6 +102,7 @@ describe('Board card layout', () => { }); it('sets showDetail to false on mousemove', async () => { + createStore(); mountComponent(); wrapper.trigger('mousedown'); await wrapper.vm.$nextTick(); @@ -92,5 +111,49 @@ describe('Board card layout', () => { await wrapper.vm.$nextTick(); expect(wrapper.vm.showDetail).toBe(false); }); + + it("calls 'setActiveId' when 'graphqlBoardLists' feature flag is turned on", async () => { + const setActiveId = jest.fn(); + createStore({ + actions: { + setActiveId, + }, + }); + mountComponent({ + provide: { + glFeatures: { graphqlBoardLists: true }, + }, + }); + + wrapper.trigger('mouseup'); + await wrapper.vm.$nextTick(); + + expect(setActiveId).toHaveBeenCalledTimes(1); + expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), { + id: list.issues[0].id, + sidebarType: ISSUABLE, + }); + }); + + it("calls 'setActiveId' when epic swimlanes is active", async () => { + const setActiveId = jest.fn(); + const isSwimlanesOn = () => true; + createStore({ + getters: { isSwimlanesOn }, + actions: { + setActiveId, + }, + }); + mountComponent(); + + wrapper.trigger('mouseup'); + await wrapper.vm.$nextTick(); + + expect(setActiveId).toHaveBeenCalledTimes(1); + expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), { + id: list.issues[0].id, + sidebarType: ISSUABLE, + }); + }); }); }); diff --git a/spec/frontend/environments/environment_table_spec.js b/spec/frontend/environments/environment_table_spec.js index c9dc29af515..daef35bcf99 100644 --- a/spec/frontend/environments/environment_table_spec.js +++ b/spec/frontend/environments/environment_table_spec.js @@ -6,8 +6,6 @@ import CanaryUpdateModal from '~/environments/components/canary_update_modal.vue import { folder, deployBoardMockData } from './mock_data'; const eeOnlyProps = { - canaryDeploymentFeatureId: 'canary_deployment', - showCanaryDeploymentCallout: true, userCalloutsPath: '/callouts', lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', helpCanaryDeploymentsPath: 'help/canary-deployments', @@ -53,8 +51,6 @@ describe('Environment table', () => { propsData: { environments: [mockItem], canReadEnvironment: true, - canaryDeploymentFeatureId: 'canary_deployment', - showCanaryDeploymentCallout: true, userCalloutsPath: '/callouts', lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', helpCanaryDeploymentsPath: 'help/canary-deployments', @@ -83,8 +79,6 @@ describe('Environment table', () => { environments: [mockItem], canCreateDeployment: false, canReadEnvironment: true, - canaryDeploymentFeatureId: 'canary_deployment', - showCanaryDeploymentCallout: true, userCalloutsPath: '/callouts', lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', helpCanaryDeploymentsPath: 'help/canary-deployments', @@ -122,8 +116,6 @@ describe('Environment table', () => { propsData: { environments: [mockItem], canReadEnvironment: true, - canaryDeploymentFeatureId: 'canary_deployment', - showCanaryDeploymentCallout: true, userCalloutsPath: '/callouts', lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', helpCanaryDeploymentsPath: 'help/canary-deployments', @@ -152,8 +144,6 @@ describe('Environment table', () => { environments: [mockItem], canCreateDeployment: false, canReadEnvironment: true, - canaryDeploymentFeatureId: 'canary_deployment', - showCanaryDeploymentCallout: true, userCalloutsPath: '/callouts', lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', helpCanaryDeploymentsPath: 'help/canary-deployments', @@ -169,32 +159,6 @@ describe('Environment table', () => { }); }); - it('should render canary callout', async () => { - const mockItem = { - name: 'review', - folderName: 'review', - size: 3, - isFolder: true, - environment_path: 'url', - showCanaryCallout: true, - }; - - await factory({ - propsData: { - environments: [mockItem], - canCreateDeployment: false, - canReadEnvironment: true, - canaryDeploymentFeatureId: 'canary_deployment', - showCanaryDeploymentCallout: true, - userCalloutsPath: '/callouts', - lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', - helpCanaryDeploymentsPath: 'help/canary-deployments', - }, - }); - - expect(wrapper.find('.canary-deployment-callout').exists()).toBe(true); - }); - describe('sortEnvironments', () => { it('should sort environments by last updated', () => { const mockItems = [ diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js index 7d61e99ec63..d6614e2fd2b 100644 --- a/spec/frontend/environments/environments_app_spec.js +++ b/spec/frontend/environments/environments_app_spec.js @@ -6,7 +6,6 @@ import Container from '~/environments/components/container.vue'; import EmptyState from '~/environments/components/empty_state.vue'; import EnvironmentsApp from '~/environments/components/environments_app.vue'; import DeployBoard from '~/environments/components/deploy_board.vue'; -import CanaryDeploymentBoard from '~/environments/components/canary_deployment_callout.vue'; import axios from '~/lib/utils/axios_utils'; import { environment, folder } from './mock_data'; @@ -20,8 +19,6 @@ describe('Environment', () => { canReadEnvironment: true, newEnvironmentPath: 'environments/new', helpPagePath: 'help', - canaryDeploymentFeatureId: 'canary_deployment', - showCanaryDeploymentCallout: true, userCalloutsPath: '/callouts', lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', helpCanaryDeploymentsPath: 'help/canary-deployments', @@ -38,9 +35,6 @@ describe('Environment', () => { }); }; - const canaryPromoKeyValue = () => - wrapper.find(CanaryDeploymentBoard).attributes('data-js-canary-promo-key'); - const createWrapper = (shallow = false, props = {}) => { const fn = shallow ? shallowMount : mount; wrapper = extendedWrapper(fn(EnvironmentsApp, { propsData: { ...mockData, ...props } })); @@ -148,28 +142,6 @@ describe('Environment', () => { ).toBe(true); }); }); - - describe('canary callout with one environment', () => { - it('should render banner underneath first environment', () => { - expect(canaryPromoKeyValue()).toBe('0'); - }); - }); - - describe('canary callout with multiple environments', () => { - beforeEach(() => { - mockRequest(200, { - environments: [environment, environment], - stopped_count: 1, - available_count: 0, - }); - - return createWrapper(); - }); - - it('should render banner underneath second environment', () => { - expect(canaryPromoKeyValue()).toBe('1'); - }); - }); }); }); diff --git a/spec/frontend/environments/environments_folder_view_spec.js b/spec/frontend/environments/environments_folder_view_spec.js index 43f809dda88..e4661d27872 100644 --- a/spec/frontend/environments/environments_folder_view_spec.js +++ b/spec/frontend/environments/environments_folder_view_spec.js @@ -13,8 +13,6 @@ describe('Environments Folder View', () => { folderName: 'review', canReadEnvironment: true, cssContainerClass: 'container', - canaryDeploymentFeatureId: 'canary_deployment', - showCanaryDeploymentCallout: true, userCalloutsPath: '/callouts', lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', helpCanaryDeploymentsPath: 'help/canary-deployments', diff --git a/spec/frontend/environments/environments_store_spec.js b/spec/frontend/environments/environments_store_spec.js index 60f0152bd47..4a07281353f 100644 --- a/spec/frontend/environments/environments_store_spec.js +++ b/spec/frontend/environments/environments_store_spec.js @@ -214,18 +214,4 @@ describe('Store', () => { expect(store.state.environments[0].deployBoardData).toEqual(deployBoardMockData); }); }); - - describe('canaryCallout', () => { - it('should add banner underneath the second environment', () => { - store.storeEnvironments(serverData); - - expect(store.state.environments[1].showCanaryCallout).toEqual(true); - }); - - it('should add banner underneath first environment when only one environment', () => { - store.storeEnvironments(serverData.slice(0, 1)); - - expect(store.state.environments[0].showCanaryCallout).toEqual(true); - }); - }); }); diff --git a/spec/frontend/environments/folder/environments_folder_view_spec.js b/spec/frontend/environments/folder/environments_folder_view_spec.js index abead001832..3943e89c6cf 100644 --- a/spec/frontend/environments/folder/environments_folder_view_spec.js +++ b/spec/frontend/environments/folder/environments_folder_view_spec.js @@ -16,8 +16,6 @@ describe('Environments Folder View', () => { folderName: 'review', canReadEnvironment: true, cssContainerClass: 'container', - canaryDeploymentFeatureId: 'canary_deployment', - showCanaryDeploymentCallout: true, userCalloutsPath: '/callouts', lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', helpCanaryDeploymentsPath: 'help/canary-deployments', diff --git a/spec/frontend/jira_connect/api_spec.js b/spec/frontend/jira_connect/api_spec.js index e5a2484c827..8fecbee9ca7 100644 --- a/spec/frontend/jira_connect/api_spec.js +++ b/spec/frontend/jira_connect/api_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; -import { addSubscription, removeSubscription } from '~/jira_connect/api'; +import { addSubscription, removeSubscription, fetchGroups } from '~/jira_connect/api'; describe('JiraConnect API', () => { let mock; @@ -72,4 +72,36 @@ describe('JiraConnect API', () => { expect(response.data).toEqual(mockResponse); }); }); + + describe('fetchGroups', () => { + const mockGroupsPath = 'groupsPath'; + const mockPage = 1; + const mockPerPage = 10; + + const makeRequest = () => + fetchGroups(mockGroupsPath, { + page: mockPage, + perPage: mockPerPage, + }); + + it('returns success response', async () => { + jest.spyOn(axios, 'get'); + mock + .onGet(mockGroupsPath, { + page: mockPage, + per_page: mockPerPage, + }) + .replyOnce(httpStatus.OK, mockResponse); + + response = await makeRequest(); + + expect(axios.get).toHaveBeenCalledWith(mockGroupsPath, { + params: { + page: mockPage, + per_page: mockPerPage, + }, + }); + expect(response.data).toEqual(mockResponse); + }); + }); }); diff --git a/spec/frontend/jira_connect/components/groups_list_item_spec.js b/spec/frontend/jira_connect/components/groups_list_item_spec.js new file mode 100644 index 00000000000..77577c53cf4 --- /dev/null +++ b/spec/frontend/jira_connect/components/groups_list_item_spec.js @@ -0,0 +1,46 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlAvatar } from '@gitlab/ui'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { mockGroup1 } from '../mock_data'; + +import GroupsListItem from '~/jira_connect/components/groups_list_item.vue'; + +describe('GroupsListItem', () => { + let wrapper; + + const createComponent = () => { + wrapper = extendedWrapper( + shallowMount(GroupsListItem, { + propsData: { + group: mockGroup1, + }, + }), + ); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findGlAvatar = () => wrapper.find(GlAvatar); + const findGroupName = () => wrapper.findByTestId('group-list-item-name'); + const findGroupDescription = () => wrapper.findByTestId('group-list-item-description'); + + it('renders group avatar', () => { + expect(findGlAvatar().exists()).toBe(true); + expect(findGlAvatar().props('src')).toBe(mockGroup1.avatar_url); + }); + + it('renders group name', () => { + expect(findGroupName().text()).toBe(mockGroup1.full_name); + }); + + it('renders group description', () => { + expect(findGroupDescription().text()).toBe(mockGroup1.description); + }); +}); diff --git a/spec/frontend/jira_connect/components/groups_list_spec.js b/spec/frontend/jira_connect/components/groups_list_spec.js new file mode 100644 index 00000000000..94f158e6344 --- /dev/null +++ b/spec/frontend/jira_connect/components/groups_list_spec.js @@ -0,0 +1,71 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; + +import { fetchGroups } from '~/jira_connect/api'; +import GroupsList from '~/jira_connect/components/groups_list.vue'; +import GroupsListItem from '~/jira_connect/components/groups_list_item.vue'; +import { mockGroup1, mockGroup2 } from '../mock_data'; + +jest.mock('~/jira_connect/api', () => { + return { + fetchGroups: jest.fn(), + }; +}); +describe('GroupsList', () => { + let wrapper; + + const mockEmptyResponse = { data: [] }; + + const createComponent = (options = {}) => { + wrapper = shallowMount(GroupsList, { + ...options, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findGlLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findAllItems = () => wrapper.findAll(GroupsListItem); + const findFirstItem = () => findAllItems().at(0); + const findSecondItem = () => findAllItems().at(1); + + describe('isLoading is true', () => { + it('renders loading icon', async () => { + fetchGroups.mockResolvedValue(mockEmptyResponse); + createComponent(); + + wrapper.setData({ isLoading: true }); + await wrapper.vm.$nextTick(); + + expect(findGlLoadingIcon().exists()).toBe(true); + }); + }); + + describe('no groups returned', () => { + it('renders empty state', async () => { + fetchGroups.mockResolvedValue(mockEmptyResponse); + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toContain('No available namespaces'); + }); + }); + + describe('with groups returned', () => { + it('renders groups list', async () => { + fetchGroups.mockResolvedValue({ data: [mockGroup1, mockGroup2] }); + createComponent(); + + await waitForPromises(); + + expect(findAllItems().length).toBe(2); + expect(findFirstItem().props('group')).toBe(mockGroup1); + expect(findSecondItem().props('group')).toBe(mockGroup2); + }); + }); +}); diff --git a/spec/frontend/jira_connect/mock_data.js b/spec/frontend/jira_connect/mock_data.js new file mode 100644 index 00000000000..31565912489 --- /dev/null +++ b/spec/frontend/jira_connect/mock_data.js @@ -0,0 +1,15 @@ +export const mockGroup1 = { + id: 1, + avatar_url: 'avatar.png', + name: 'Gitlab Org', + full_name: 'Gitlab Org', + description: 'Open source software to collaborate on code', +}; + +export const mockGroup2 = { + id: 2, + avatar_url: 'avatar.png', + name: 'Gitlab Com', + full_name: 'Gitlab Com', + description: 'For GitLab company related projects', +}; diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js index 3f0d73cbb10..66efd43262b 100644 --- a/spec/frontend/lib/utils/datetime_utility_spec.js +++ b/spec/frontend/lib/utils/datetime_utility_spec.js @@ -842,3 +842,58 @@ describe('format24HourTimeStringFromInt', () => { }); }); }); + +describe('getOverlappingDaysInPeriods', () => { + const start = new Date(2021, 0, 11); + const end = new Date(2021, 0, 13); + + describe('when date periods overlap', () => { + const givenPeriodLeft = new Date(2021, 0, 11); + const givenPeriodRight = new Date(2021, 0, 14); + + it('returns an overlap object that contains the amount of days overlapping, start date of overlap and end date of overlap', () => { + expect( + datetimeUtility.getOverlappingDaysInPeriods( + { start, end }, + { start: givenPeriodLeft, end: givenPeriodRight }, + ), + ).toEqual({ + daysOverlap: 2, + overlapStartDate: givenPeriodLeft.getTime(), + overlapEndDate: end.getTime(), + }); + }); + }); + + describe('when date periods do not overlap', () => { + const givenPeriodLeft = new Date(2021, 0, 9); + const givenPeriodRight = new Date(2021, 0, 10); + + it('returns an overlap object that contains a 0 value for days overlapping', () => { + expect( + datetimeUtility.getOverlappingDaysInPeriods( + { start, end }, + { start: givenPeriodLeft, end: givenPeriodRight }, + ), + ).toEqual({ daysOverlap: 0 }); + }); + }); + + describe('when date periods contain an invalid Date', () => { + const startInvalid = new Date(NaN); + const endInvalid = new Date(NaN); + const error = __('Invalid period'); + + it('throws an exception when the left period contains an invalid date', () => { + expect(() => + datetimeUtility.getOverlappingDaysInPeriods({ start, end }, { start: startInvalid, end }), + ).toThrow(error); + }); + + it('throws an exception when the right period contains an invalid date', () => { + expect(() => + datetimeUtility.getOverlappingDaysInPeriods({ start, end }, { start, end: endInvalid }), + ).toThrow(error); + }); + }); +}); diff --git a/spec/graphql/types/notes/note_type_spec.rb b/spec/graphql/types/notes/note_type_spec.rb index 180d13d35d2..03ff7828cf5 100644 --- a/spec/graphql/types/notes/note_type_spec.rb +++ b/spec/graphql/types/notes/note_type_spec.rb @@ -22,6 +22,7 @@ RSpec.describe GitlabSchema.types['Note'] do system_note_icon_name updated_at user_permissions + url ] expect(described_class).to have_graphql_fields(*expected_fields) diff --git a/spec/helpers/jira_connect_helper_spec.rb b/spec/helpers/jira_connect_helper_spec.rb new file mode 100644 index 00000000000..a99072527c8 --- /dev/null +++ b/spec/helpers/jira_connect_helper_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe JiraConnectHelper do + describe '#jira_connect_app_data' do + subject { helper.jira_connect_app_data } + + it 'includes Jira Connect app attributes' do + is_expected.to include( + :groups_path + ) + end + end +end diff --git a/spec/lib/atlassian/jira_connect/client_spec.rb b/spec/lib/atlassian/jira_connect/client_spec.rb index e042b2b183d..21ee40f22fe 100644 --- a/spec/lib/atlassian/jira_connect/client_spec.rb +++ b/spec/lib/atlassian/jira_connect/client_spec.rb @@ -107,6 +107,75 @@ RSpec.describe Atlassian::JiraConnect::Client do } end + describe '#handle_response' do + let(:errors) { [{ 'message' => 'X' }, { 'message' => 'Y' }] } + let(:processed) { subject.send(:handle_response, response, 'foo') { |x| [:data, x] } } + + context 'the response is 200 OK' do + let(:response) { double(code: 200, parsed_response: :foo) } + + it 'yields to the block' do + expect(processed).to eq [:data, :foo] + end + end + + context 'the response is 400 bad request' do + let(:response) { double(code: 400, parsed_response: errors) } + + it 'extracts the errors messages' do + expect(processed).to eq('errorMessages' => %w(X Y)) + end + end + + context 'the response is 401 forbidden' do + let(:response) { double(code: 401, parsed_response: nil) } + + it 'reports that our JWT is wrong' do + expect(processed).to eq('errorMessages' => ['Invalid JWT']) + end + end + + context 'the response is 403' do + let(:response) { double(code: 403, parsed_response: nil) } + + it 'reports that the App is misconfigured' do + expect(processed).to eq('errorMessages' => ['App does not support foo']) + end + end + + context 'the response is 413' do + let(:response) { double(code: 413, parsed_response: errors) } + + it 'extracts the errors messages' do + expect(processed).to eq('errorMessages' => ['Data too large', 'X', 'Y']) + end + end + + context 'the response is 429' do + let(:response) { double(code: 429, parsed_response: nil) } + + it 'reports that we exceeded the rate limit' do + expect(processed).to eq('errorMessages' => ['Rate limit exceeded']) + end + end + + context 'the response is 503' do + let(:response) { double(code: 503, parsed_response: nil) } + + it 'reports that the service is unavailable' do + expect(processed).to eq('errorMessages' => ['Service unavailable']) + end + end + + context 'the response is anything else' do + let(:response) { double(code: 1000, parsed_response: :something) } + + it 'reports that this was unanticipated' do + expect(processed).to eq('errorMessages' => ['Unknown error'], 'response' => :something) + end + end + end + describe '#store_deploy_info' do let_it_be(:environment) { create(:environment, name: 'DEV', project: project) } let_it_be(:deployments) do @@ -126,10 +195,20 @@ RSpec.describe Atlassian::JiraConnect::Client do ->(text) { matcher.matches?(text) } end + let(:rejections) { [] } + let(:response_body) do + { + acceptedDeployments: [], + rejectedDeployments: rejections, + unknownIssueKeys: [] + }.to_json + end + before do path = '/rest/deployments/0.1/bulk' stub_full_request('https://gitlab-test.atlassian.net' + path, method: :post) .with(body: body, headers: expected_headers(path)) + .to_return(body: response_body, headers: { 'Content-Type': 'application/json' }) end it "calls the API with auth headers" do @@ -137,7 +216,7 @@ RSpec.describe Atlassian::JiraConnect::Client do end it 'only sends information about relevant MRs' do - expect(subject).to receive(:post).with('/rest/deployments/0.1/bulk', { deployments: have_attributes(size: 6) }) + expect(subject).to receive(:post).with('/rest/deployments/0.1/bulk', { deployments: have_attributes(size: 6) }).and_call_original subject.send(:store_deploy_info, project: project, deployments: deployments) end @@ -148,6 +227,18 @@ RSpec.describe Atlassian::JiraConnect::Client do subject.send(:store_deploy_info, project: project, deployments: deployments.take(1)) end + context 'there are errors' do + let(:rejections) do + [{ errors: [{ message: 'X' }, { message: 'Y' }] }, { errors: [{ message: 'Z' }] }] + end + + it 'reports the errors' do + response = subject.send(:store_deploy_info, project: project, deployments: deployments) + + expect(response['errorMessages']).to eq(%w(X Y Z)) + end + end + it 'does not call the API if the feature flag is not enabled' do stub_feature_flags(jira_sync_deployments: false) @@ -159,7 +250,7 @@ RSpec.describe Atlassian::JiraConnect::Client do it 'does call the API if the feature flag enabled for the project' do stub_feature_flags(jira_sync_deployments: project) - expect(subject).to receive(:post).with('/rest/deployments/0.1/bulk', { deployments: Array }) + expect(subject).to receive(:post).with('/rest/deployments/0.1/bulk', { deployments: Array }).and_call_original subject.send(:store_deploy_info, project: project, deployments: deployments) end @@ -178,12 +269,22 @@ RSpec.describe Atlassian::JiraConnect::Client do ->(text) { matcher.matches?(text) } end + let(:failures) { {} } + let(:response_body) do + { + acceptedFeatureFlags: [], + failedFeatureFlags: failures, + unknownIssueKeys: [] + }.to_json + end + before do feature_flags.first.update!(description: 'RELEVANT-123') feature_flags.second.update!(description: 'RELEVANT-123') path = '/rest/featureflags/0.1/bulk' stub_full_request('https://gitlab-test.atlassian.net' + path, method: :post) .with(body: body, headers: expected_headers(path)) + .to_return(body: response_body, headers: { 'Content-Type': 'application/json' }) end it "calls the API with auth headers" do @@ -193,7 +294,7 @@ RSpec.describe Atlassian::JiraConnect::Client do it 'only sends information about relevant MRs' do expect(subject).to receive(:post).with('/rest/featureflags/0.1/bulk', { flags: have_attributes(size: 2), properties: Hash - }) + }).and_call_original subject.send(:store_ff_info, project: project, feature_flags: feature_flags) end @@ -204,6 +305,21 @@ RSpec.describe Atlassian::JiraConnect::Client do subject.send(:store_ff_info, project: project, feature_flags: [feature_flags.last]) end + context 'there are errors' do + let(:failures) do + { + a: [{ message: 'X' }, { message: 'Y' }], + b: [{ message: 'Z' }] + } + end + + it 'reports the errors' do + response = subject.send(:store_ff_info, project: project, feature_flags: feature_flags) + + expect(response['errorMessages']).to eq(['a: X', 'a: Y', 'b: Z']) + end + end + it 'does not call the API if the feature flag is not enabled' do stub_feature_flags(jira_sync_feature_flags: false) @@ -217,7 +333,7 @@ RSpec.describe Atlassian::JiraConnect::Client do expect(subject).to receive(:post).with('/rest/featureflags/0.1/bulk', { flags: Array, properties: Hash - }) + }).and_call_original subject.send(:store_ff_info, project: project, feature_flags: feature_flags) end @@ -234,10 +350,20 @@ RSpec.describe Atlassian::JiraConnect::Client do ->(text) { matcher.matches?(text) } end + let(:failures) { [] } + let(:response_body) do + { + acceptedBuilds: [], + rejectedBuilds: failures, + unknownIssueKeys: [] + }.to_json + end + before do path = '/rest/builds/0.1/bulk' stub_full_request('https://gitlab-test.atlassian.net' + path, method: :post) .with(body: body, headers: expected_headers(path)) + .to_return(body: response_body, headers: { 'Content-Type': 'application/json' }) end it "calls the API with auth headers" do @@ -245,7 +371,9 @@ RSpec.describe Atlassian::JiraConnect::Client do end it 'only sends information about relevant MRs' do - expect(subject).to receive(:post).with('/rest/builds/0.1/bulk', { builds: have_attributes(size: 6) }) + expect(subject).to receive(:post) + .with('/rest/builds/0.1/bulk', { builds: have_attributes(size: 6) }) + .and_call_original subject.send(:store_build_info, project: project, pipelines: pipelines) end @@ -267,11 +395,25 @@ RSpec.describe Atlassian::JiraConnect::Client do it 'does call the API if the feature flag enabled for the project' do stub_feature_flags(jira_sync_builds: project) - expect(subject).to receive(:post).with('/rest/builds/0.1/bulk', { builds: Array }) + expect(subject).to receive(:post) + .with('/rest/builds/0.1/bulk', { builds: Array }) + .and_call_original subject.send(:store_build_info, project: project, pipelines: pipelines) end + context 'there are errors' do + let(:failures) do + [{ errors: [{ message: 'X' }, { message: 'Y' }] }, { errors: [{ message: 'Z' }] }] + end + + it 'reports the errors' do + response = subject.send(:store_build_info, project: project, pipelines: pipelines) + + expect(response['errorMessages']).to eq(%w(X Y Z)) + end + end + it 'avoids N+1 database queries' do pending 'https://gitlab.com/gitlab-org/gitlab/-/issues/292818' diff --git a/spec/lib/gitlab/experimentation/controller_concern_spec.rb b/spec/lib/gitlab/experimentation/controller_concern_spec.rb index c47f71c207d..1cebe37bea5 100644 --- a/spec/lib/gitlab/experimentation/controller_concern_spec.rb +++ b/spec/lib/gitlab/experimentation/controller_concern_spec.rb @@ -10,6 +10,10 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do use_backwards_compatible_subject_index: true }, test_experiment: { + tracking_category: 'Team', + rollout_strategy: rollout_strategy + }, + my_experiment: { tracking_category: 'Team' } } @@ -20,6 +24,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do end let(:enabled_percentage) { 10 } + let(:rollout_strategy) { nil } controller(ApplicationController) do include Gitlab::Experimentation::ControllerConcern @@ -117,6 +122,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do end context 'when subject is given' do + let(:rollout_strategy) { :user } let(:user) { build(:user) } it 'uses the subject' do @@ -244,6 +250,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do it "provides the subject's hashed global_id as label" do experiment_subject = double(:subject, to_global_id: 'abc') + allow(Gitlab::Experimentation).to receive(:valid_subject_for_rollout_strategy?).and_return(true) controller.track_experiment_event(:test_experiment, 'start', 1, subject: experiment_subject) @@ -420,6 +427,26 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do controller.record_experiment_user(:test_experiment, context) end + + context 'with a cookie based rollout strategy' do + it 'calls tracking_group with a nil subject' do + expect(controller).to receive(:tracking_group).with(:test_experiment, nil, subject: nil).and_return(:experimental) + allow(::Experiment).to receive(:add_user).with(:test_experiment, :experimental, user, context) + + controller.record_experiment_user(:test_experiment, context) + end + end + + context 'with a user based rollout strategy' do + let(:rollout_strategy) { :user } + + it 'calls tracking_group with a user subject' do + expect(controller).to receive(:tracking_group).with(:test_experiment, nil, subject: user).and_return(:experimental) + allow(::Experiment).to receive(:add_user).with(:test_experiment, :experimental, user, context) + + controller.record_experiment_user(:test_experiment, context) + end + end end context 'the user is part of the control group' do diff --git a/spec/lib/gitlab/experimentation/experiment_spec.rb b/spec/lib/gitlab/experimentation/experiment_spec.rb index 008e6699597..94dbf1d7e4b 100644 --- a/spec/lib/gitlab/experimentation/experiment_spec.rb +++ b/spec/lib/gitlab/experimentation/experiment_spec.rb @@ -9,7 +9,8 @@ RSpec.describe Gitlab::Experimentation::Experiment do let(:params) do { tracking_category: 'Category1', - use_backwards_compatible_subject_index: true + use_backwards_compatible_subject_index: true, + rollout_strategy: nil } end diff --git a/spec/lib/gitlab/experimentation_spec.rb b/spec/lib/gitlab/experimentation_spec.rb index b503960b8c7..71d08903532 100644 --- a/spec/lib/gitlab/experimentation_spec.rb +++ b/spec/lib/gitlab/experimentation_spec.rb @@ -102,7 +102,11 @@ RSpec.describe Gitlab::Experimentation do context 'when subject has a global_id' do let(:experiment_subject) { double(:subject, to_global_id: 'z') } - it { is_expected.to eq(true) } + it do + allow(Gitlab::Experimentation).to receive(:valid_subject_for_rollout_strategy?).and_return(true) + + is_expected.to eq(true) + end end context 'when subject is nil' do @@ -150,7 +154,11 @@ RSpec.describe Gitlab::Experimentation do context 'when subject has a global_id' do let(:experiment_subject) { double(:subject, to_global_id: 'abcd') } - it { is_expected.to eq(true) } + it do + allow(Gitlab::Experimentation).to receive(:valid_subject_for_rollout_strategy?).and_return(true) + + is_expected.to eq(true) + end end context 'when subject is nil' do diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb index 8e9f7e372c5..cd89674af0f 100644 --- a/spec/lib/gitlab/path_regex_spec.rb +++ b/spec/lib/gitlab/path_regex_spec.rb @@ -102,6 +102,7 @@ RSpec.describe Gitlab::PathRegex do .concat(files_in_public) .concat(Array(API::API.prefix.to_s)) .concat(sitemap_words) + .concat(deprecated_routes) .compact .uniq end @@ -110,6 +111,11 @@ RSpec.describe Gitlab::PathRegex do %w(sitemap sitemap.xml sitemap.xml.gz) end + let(:deprecated_routes) do + # profile was deprecated in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/51646 + %w(profile) + end + let(:ee_top_level_words) do %w(unsubscribes v2) end diff --git a/spec/requests/profiles/notifications_controller_spec.rb b/spec/requests/profiles/notifications_controller_spec.rb index 87669b3594c..d7dfb1c675d 100644 --- a/spec/requests/profiles/notifications_controller_spec.rb +++ b/spec/requests/profiles/notifications_controller_spec.rb @@ -24,7 +24,7 @@ RSpec.describe 'view user notifications' do get profile_notifications_path end - describe 'GET /profile/notifications' do + describe 'GET /-/profile/notifications' do it 'does not have an N+1 due to an additional groups (with no parent group)' do get_profile_notifications diff --git a/spec/routing/notifications_routing_spec.rb b/spec/routing/notifications_routing_spec.rb index 007e8ff4816..d66aa7f219f 100644 --- a/spec/routing/notifications_routing_spec.rb +++ b/spec/routing/notifications_routing_spec.rb @@ -4,15 +4,15 @@ require "spec_helper" RSpec.describe "notifications routing" do it "routes to #show" do - expect(get("/profile/notifications")).to route_to("profiles/notifications#show") + expect(get("/-/profile/notifications")).to route_to("profiles/notifications#show") end it "routes to #update" do - expect(put("/profile/notifications")).to route_to("profiles/notifications#update") + expect(put("/-/profile/notifications")).to route_to("profiles/notifications#update") end it 'routes to group #update' do - expect(put("/profile/notifications/groups/gitlab-org")).to route_to("profiles/groups#update", id: 'gitlab-org') - expect(put("/profile/notifications/groups/gitlab.org")).to route_to("profiles/groups#update", id: 'gitlab.org') + expect(put("/-/profile/notifications/groups/gitlab-org")).to route_to("profiles/groups#update", id: 'gitlab-org') + expect(put("/-/profile/notifications/groups/gitlab.org")).to route_to("profiles/groups#update", id: 'gitlab.org') end end diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb index a683dc28f4f..29e5c1b4bae 100644 --- a/spec/routing/project_routing_spec.rb +++ b/spec/routing/project_routing_spec.rb @@ -106,8 +106,8 @@ RSpec.describe 'project routing' do let(:base_path) { '/gitlab/gitlabhq/-/wikis' } end - it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/wikis", "/gitlab/gitlabhq/-/wikis" - it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/wikis/home/edit", "/gitlab/gitlabhq/-/wikis/home/edit" + it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/wikis", "/gitlab/gitlabhq/-/wikis" + it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/wikis/home/edit", "/gitlab/gitlabhq/-/wikis/home/edit" end # branches_project_repository GET /:project_id/repository/branches(.:format) projects/repositories#branches @@ -171,7 +171,7 @@ RSpec.describe 'project routing' do expect(delete('/gitlab/gitlabhq/-/tags/feature@45/foo/bar/baz')).to route_to('projects/tags#destroy', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'feature@45/foo/bar/baz') end - it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/tags", "/gitlab/gitlabhq/-/tags" + it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/tags", "/gitlab/gitlabhq/-/tags" end # project_deploy_keys GET /:project_id/deploy_keys(.:format) deploy_keys#index @@ -259,8 +259,8 @@ RSpec.describe 'project routing' do let(:base_path) { '/gitlab/gitlabhq/-/merge_requests' } end - it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/merge_requests", "/gitlab/gitlabhq/-/merge_requests" - it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/merge_requests/1/diffs", "/gitlab/gitlabhq/-/merge_requests/1/diffs" + it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/merge_requests", "/gitlab/gitlabhq/-/merge_requests" + it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/merge_requests/1/diffs", "/gitlab/gitlabhq/-/merge_requests/1/diffs" end describe Projects::MergeRequests::CreationsController, 'routing' do @@ -290,7 +290,7 @@ RSpec.describe 'project routing' do expect(get('/gitlab/gitlabhq/-/merge_requests/new/diffs.json')).to route_to('projects/merge_requests/creations#diffs', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'json') end - it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/merge_requests/new", "/gitlab/gitlabhq/-/merge_requests/new" + it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/merge_requests/new", "/gitlab/gitlabhq/-/merge_requests/new" end describe Projects::MergeRequests::DiffsController, 'routing' do @@ -454,8 +454,8 @@ RSpec.describe 'project routing' do let(:base_path) { '/gitlab/gitlabhq/-/issues' } end - it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/issues", "/gitlab/gitlabhq/-/issues" - it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/issues/1/edit", "/gitlab/gitlabhq/-/issues/1/edit" + it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/issues", "/gitlab/gitlabhq/-/issues" + it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/issues/1/edit", "/gitlab/gitlabhq/-/issues/1/edit" end # project_noteable_notes GET /:project_id/noteable/:target_type/:target_id/notes notes#index @@ -769,25 +769,25 @@ RSpec.describe 'project routing' do describe Projects::EnvironmentsController, 'routing' do describe 'legacy routing' do - it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/environments", "/gitlab/gitlabhq/-/environments" + it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/environments", "/gitlab/gitlabhq/-/environments" end end describe Projects::ClustersController, 'routing' do describe 'legacy routing' do - it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/clusters", "/gitlab/gitlabhq/-/clusters" + it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/clusters", "/gitlab/gitlabhq/-/clusters" end end describe Projects::ErrorTrackingController, 'routing' do describe 'legacy routing' do - it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/error_tracking", "/gitlab/gitlabhq/-/error_tracking" + it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/error_tracking", "/gitlab/gitlabhq/-/error_tracking" end end describe Projects::Serverless, 'routing' do describe 'legacy routing' do - it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/serverless", "/gitlab/gitlabhq/-/serverless" + it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/serverless", "/gitlab/gitlabhq/-/serverless" end end diff --git a/spec/routing/routing_spec.rb b/spec/routing/routing_spec.rb index 26ad1f14786..7b9ba783885 100644 --- a/spec/routing/routing_spec.rb +++ b/spec/routing/routing_spec.rb @@ -122,111 +122,115 @@ RSpec.describe HelpController, "routing" do end end -# profile_account GET /profile/account(.:format) profile#account -# profile_history GET /profile/history(.:format) profile#history -# profile_password PUT /profile/password(.:format) profile#password_update -# profile_token GET /profile/token(.:format) profile#token -# profile GET /profile(.:format) profile#show -# profile_update PUT /profile/update(.:format) profile#update +# profile_account GET /-/profile/account(.:format) profile#account +# profile_history GET /-/profile/history(.:format) profile#history +# profile_password PUT /-/profile/password(.:format) profile#password_update +# profile_token GET /-/profile/token(.:format) profile#token +# profile GET /-/profile(.:format) profile#show +# profile_update PUT /-/profile/update(.:format) profile#update RSpec.describe ProfilesController, "routing" do it "to #account" do - expect(get("/profile/account")).to route_to('profiles/accounts#show') + expect(get("/-/profile/account")).to route_to('profiles/accounts#show') end + it_behaves_like 'redirecting a legacy path', '/profile/account', '/-/profile/account' it "to #audit_log" do - expect(get("/profile/audit_log")).to route_to('profiles#audit_log') + expect(get("/-/profile/audit_log")).to route_to('profiles#audit_log') end + it_behaves_like 'redirecting a legacy path', '/profile/audit_log', '/-/profile/audit_log' it "to #reset_feed_token" do - expect(put("/profile/reset_feed_token")).to route_to('profiles#reset_feed_token') + expect(put("/-/profile/reset_feed_token")).to route_to('profiles#reset_feed_token') end it "to #show" do - expect(get("/profile")).to route_to('profiles#show') - end - - it 'to #show from scope routing' do expect(get("/-/profile")).to route_to('profiles#show') end + it_behaves_like 'redirecting a legacy path', '/profile', '/-/profile' end -# profile_preferences GET /profile/preferences(.:format) profiles/preferences#show -# PATCH /profile/preferences(.:format) profiles/preferences#update -# PUT /profile/preferences(.:format) profiles/preferences#update +# profile_preferences GET /-/profile/preferences(.:format) profiles/preferences#show +# PATCH /-/profile/preferences(.:format) profiles/preferences#update +# PUT /-/profile/preferences(.:format) profiles/preferences#update RSpec.describe Profiles::PreferencesController, 'routing' do it 'to #show' do - expect(get('/profile/preferences')).to route_to('profiles/preferences#show') + expect(get('/-/profile/preferences')).to route_to('profiles/preferences#show') end + it_behaves_like 'redirecting a legacy path', '/profile/preferences', '/-/profile/preferences' it 'to #update' do - expect(put('/profile/preferences')).to route_to('profiles/preferences#update') - expect(patch('/profile/preferences')).to route_to('profiles/preferences#update') + expect(put('/-/profile/preferences')).to route_to('profiles/preferences#update') + expect(patch('/-/profile/preferences')).to route_to('profiles/preferences#update') end end -# keys GET /keys(.:format) keys#index -# POST /keys(.:format) keys#create -# edit_key GET /keys/:id/edit(.:format) keys#edit -# key GET /keys/:id(.:format) keys#show -# PUT /keys/:id(.:format) keys#update -# DELETE /keys/:id(.:format) keys#destroy +# keys GET /-/profile/keys(.:format) keys#index +# POST /-/profile/keys(.:format) keys#create +# edit_key GET /-/profile/keys/:id/edit(.:format) keys#edit +# key GET /-/profile/keys/:id(.:format) keys#show +# PUT /-/profile/keys/:id(.:format) keys#update +# DELETE /-/profile/keys/:id(.:format) keys#destroy RSpec.describe Profiles::KeysController, "routing" do it "to #index" do - expect(get("/profile/keys")).to route_to('profiles/keys#index') + expect(get("/-/profile/keys")).to route_to('profiles/keys#index') end + it_behaves_like 'redirecting a legacy path', '/profile/keys', '/-/profile/keys' it "to #create" do - expect(post("/profile/keys")).to route_to('profiles/keys#create') + expect(post("/-/profile/keys")).to route_to('profiles/keys#create') end it "to #show" do - expect(get("/profile/keys/1")).to route_to('profiles/keys#show', id: '1') + expect(get("/-/profile/keys/1")).to route_to('profiles/keys#show', id: '1') end + it_behaves_like 'redirecting a legacy path', '/profile/keys/1', '/-/profile/keys/1' it "to #destroy" do - expect(delete("/profile/keys/1")).to route_to('profiles/keys#destroy', id: '1') + expect(delete("/-/profile/keys/1")).to route_to('profiles/keys#destroy', id: '1') end end -# keys GET /gpg_keys gpg_keys#index -# key POST /gpg_keys gpg_keys#create -# PUT /gpg_keys/:id gpg_keys#revoke -# DELETE /gpg_keys/:id gpg_keys#desroy +# keys GET /-/profile/gpg_keys gpg_keys#index +# key POST /-/profile/gpg_keys gpg_keys#create +# PUT /-/profile/gpg_keys/:id gpg_keys#revoke +# DELETE /-/profile/gpg_keys/:id gpg_keys#desroy RSpec.describe Profiles::GpgKeysController, "routing" do it "to #index" do - expect(get("/profile/gpg_keys")).to route_to('profiles/gpg_keys#index') + expect(get("/-/profile/gpg_keys")).to route_to('profiles/gpg_keys#index') end + it_behaves_like 'redirecting a legacy path', '/profile/gpg_keys', '/-/profile/gpg_keys' it "to #create" do - expect(post("/profile/gpg_keys")).to route_to('profiles/gpg_keys#create') + expect(post("/-/profile/gpg_keys")).to route_to('profiles/gpg_keys#create') end it "to #destroy" do - expect(delete("/profile/gpg_keys/1")).to route_to('profiles/gpg_keys#destroy', id: '1') + expect(delete("/-/profile/gpg_keys/1")).to route_to('profiles/gpg_keys#destroy', id: '1') end end -# emails GET /emails(.:format) emails#index -# POST /keys(.:format) emails#create -# DELETE /keys/:id(.:format) keys#destroy +# emails GET /-/profile/emails(.:format) emails#index +# POST /-/profile/emails(.:format) emails#create +# DELETE /-/profile/emails/:id(.:format) keys#destroy RSpec.describe Profiles::EmailsController, "routing" do it "to #index" do - expect(get("/profile/emails")).to route_to('profiles/emails#index') + expect(get("/-/profile/emails")).to route_to('profiles/emails#index') end + it_behaves_like 'redirecting a legacy path', '/profile/emails', '/-/profile/emails' it "to #create" do - expect(post("/profile/emails")).to route_to('profiles/emails#create') + expect(post("/-/profile/emails")).to route_to('profiles/emails#create') end it "to #destroy" do - expect(delete("/profile/emails/1")).to route_to('profiles/emails#destroy', id: '1') + expect(delete("/-/profile/emails/1")).to route_to('profiles/emails#destroy', id: '1') end end -# profile_avatar DELETE /profile/avatar(.:format) profiles/avatars#destroy +# profile_avatar DELETE /-/profile/avatar(.:format) profiles/avatars#destroy RSpec.describe Profiles::AvatarsController, "routing" do it "to #destroy" do - expect(delete("/profile/avatar")).to route_to('profiles/avatars#destroy') + expect(delete("/-/profile/avatar")).to route_to('profiles/avatars#destroy') end end diff --git a/spec/services/jira_connect/sync_service_spec.rb b/spec/services/jira_connect/sync_service_spec.rb index 4b434348146..edd0bad70f5 100644 --- a/spec/services/jira_connect/sync_service_spec.rb +++ b/spec/services/jira_connect/sync_service_spec.rb @@ -45,11 +45,11 @@ RSpec.describe JiraConnect::SyncService do it 'logs the response as an error' do expect_next(client).to store_info([ { 'errorMessages' => ['some error message'] }, - { 'rejectedBuilds' => ['x'] } + { 'errorMessages' => ['x'] } ]) expect_log(:error, { 'errorMessages' => ['some error message'] }) - expect_log(:error, { 'rejectedBuilds' => ['x'] }) + expect_log(:error, { 'errorMessages' => ['x'] }) subject end diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb index a15f6bdbe2c..a6730c5de52 100644 --- a/spec/services/projects/update_pages_service_spec.rb +++ b/spec/services/projects/update_pages_service_spec.rb @@ -79,6 +79,19 @@ RSpec.describe Projects::UpdatePagesService do end end + it 'fails if sha on branch was updated before deployment was uploaded' do + expect(subject).to receive(:create_pages_deployment).and_wrap_original do |m, *args| + build.update!(ref: 'feature') + m.call(*args) + end + + expect(execute).not_to eq(:success) + expect(project.pages_metadatum).not_to be_deployed + + expect(deploy_status).to be_failed + expect(deploy_status.description).to eq('build SHA is outdated for this ref') + end + it 'does not fail if pages_metadata is absent' do project.pages_metadatum.destroy! project.reload diff --git a/spec/support/shared_examples/routing/legacy_path_redirect_shared_examples.rb b/spec/support/shared_examples/routing/legacy_path_redirect_shared_examples.rb index 808336db7b1..ae3f6425b5e 100644 --- a/spec/support/shared_examples/routing/legacy_path_redirect_shared_examples.rb +++ b/spec/support/shared_examples/routing/legacy_path_redirect_shared_examples.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.shared_examples 'redirecting a legacy project path' do |source, target| +RSpec.shared_examples 'redirecting a legacy path' do |source, target| include RSpec::Rails::RequestExampleGroup it "redirects #{source} to #{target}" do diff --git a/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb b/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb index 01bd856e2a8..e6592f7f204 100644 --- a/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb +++ b/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe ContainerExpirationPolicies::CleanupContainerRepositoryWorker do + using RSpec::Parameterized::TableSyntax + let_it_be(:repository, reload: true) { create(:container_repository, :cleanup_scheduled) } let_it_be(:project) { repository.project } let_it_be(:policy) { project.container_expiration_policy } @@ -42,11 +44,32 @@ RSpec.describe ContainerExpirationPolicies::CleanupContainerRepositoryWorker do service_response = cleanup_service_response(status: :unfinished, repository: repository, cleanup_tags_service_after_truncate_size: 10, cleanup_tags_service_before_delete_size: 5) expect(ContainerExpirationPolicies::CleanupService) .to receive(:new).with(repository).and_return(double(execute: service_response)) - expect_log_extra_metadata(service_response: service_response, cleanup_status: :unfinished) + expect_log_extra_metadata(service_response: service_response, cleanup_status: :unfinished, truncated: true) subject end end + + context 'the truncated log field' do + where(:before_truncate_size, :after_truncate_size, :truncated) do + 100 | 100 | false + 100 | 80 | true + nil | 100 | false + 100 | nil | false + nil | nil | false + end + + with_them do + it 'is logged properly' do + service_response = cleanup_service_response(status: :unfinished, repository: repository, cleanup_tags_service_after_truncate_size: after_truncate_size, cleanup_tags_service_before_truncate_size: before_truncate_size) + expect(ContainerExpirationPolicies::CleanupService) + .to receive(:new).with(repository).and_return(double(execute: service_response)) + expect_log_extra_metadata(service_response: service_response, cleanup_status: :unfinished, truncated: truncated) + + subject + end + end + end end context 'with policy running shortly' do @@ -189,13 +212,14 @@ RSpec.describe ContainerExpirationPolicies::CleanupContainerRepositoryWorker do ) end - def expect_log_extra_metadata(service_response:, cleanup_status: :finished) + def expect_log_extra_metadata(service_response:, cleanup_status: :finished, truncated: false) expect(worker).to receive(:log_extra_metadata_on_done).with(:cleanup_status, cleanup_status) expect(worker).to receive(:log_extra_metadata_on_done).with(:container_repository_id, repository.id) - expect(worker).to receive(:log_extra_metadata_on_done).with(:cleanup_tags_service_original_size, service_response.payload[:cleanup_tags_service_original_size]) - expect(worker).to receive(:log_extra_metadata_on_done).with(:cleanup_tags_service_before_truncate_size, service_response.payload[:cleanup_tags_service_before_truncate_size]) - expect(worker).to receive(:log_extra_metadata_on_done).with(:cleanup_tags_service_after_truncate_size, service_response.payload[:cleanup_tags_service_after_truncate_size]) - expect(worker).to receive(:log_extra_metadata_on_done).with(:cleanup_tags_service_before_delete_size, service_response.payload[:cleanup_tags_service_before_delete_size]) + %i[cleanup_tags_service_original_size cleanup_tags_service_before_truncate_size cleanup_tags_service_after_truncate_size cleanup_tags_service_before_delete_size].each do |field| + value = service_response.payload[field] + expect(worker).to receive(:log_extra_metadata_on_done).with(field, value) unless value.nil? + end + expect(worker).to receive(:log_extra_metadata_on_done).with(:cleanup_tags_service_truncated, truncated) end end diff --git a/yarn.lock b/yarn.lock index af3b39f4d67..e21ccf83cc9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -876,10 +876,10 @@ resolved "https://registry.yarnpkg.com/@gitlab/tributejs/-/tributejs-1.0.0.tgz#672befa222aeffc83e7d799b0500a7a4418e59b8" integrity sha512-nmKw1+hB6MHvlmPz63yPwVs1qQkycHwsKgxpEbzmky16Y6mL4EJMk3w1b8QlOAF/AIAzjCERPhe/R4MJiohbZw== -"@gitlab/ui@25.11.1": - version "25.11.1" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-25.11.1.tgz#67133b99583e15497166db75781bc1103e7904ac" - integrity sha512-5Ig9QjSH8iK6XqFafPUT051M8/aUvvASJ3Bv7UFnmLZdZxiKjY6QKiD/lwleQJKBf2XAoGB13c0gfQInZI4+IQ== +"@gitlab/ui@25.11.3": + version "25.11.3" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-25.11.3.tgz#54719d1276f417e66904f9f951671633f1647006" + integrity sha512-ur8UfgJ7giQZtp7pbVAwRYSWoxOzsFTpx/OpDge5EnmrH3S6YT0BOPxYs9T2HcMYN2Cejft1rhFJY+aPGxqxJA== dependencies: "@babel/standalone" "^7.0.0" "@gitlab/vue-toasted" "^1.3.0" |