diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-01-27 18:14:51 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-01-27 18:14:51 +0300 |
commit | 48f93eadd0c117a41b4e29e3d335f451a5e6e52f (patch) | |
tree | b7ca830e2b28309a5a1fd61a6b6c646485d710f2 | |
parent | 115190b6cd73b4ba1948cf26ea883738934c614b (diff) |
Add latest changes from gitlab-org/gitlab@master
63 files changed, 969 insertions, 473 deletions
diff --git a/.rubocop_todo/database/multiple_databases.yml b/.rubocop_todo/database/multiple_databases.yml index 7329d178dc1..28dddef7c79 100644 --- a/.rubocop_todo/database/multiple_databases.yml +++ b/.rubocop_todo/database/multiple_databases.yml @@ -19,7 +19,6 @@ Database/MultipleDatabases: - lib/gitlab/database/migrations/observers/query_log.rb - lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table.rb - lib/gitlab/database.rb - - lib/gitlab/database/with_lock_retries.rb - lib/gitlab/gitlab_import/importer.rb - lib/gitlab/health_checks/db_check.rb - lib/gitlab/import_export/base/relation_factory.rb diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 8b63c5b3f5a..580ba589977 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -4b380e760d37508a2dc3c8e6a8fe1cfaae846916 +b7f0c0462a8f689c8ee9e654f0875157b238158b diff --git a/app/assets/javascripts/environments/components/deployment.vue b/app/assets/javascripts/environments/components/deployment.vue index 60ea837736c..b36d0d5f2e0 100644 --- a/app/assets/javascripts/environments/components/deployment.vue +++ b/app/assets/javascripts/environments/components/deployment.vue @@ -1,5 +1,6 @@ <script> -import { GlBadge, GlIcon, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import { GlBadge, GlButton, GlCollapse, GlIcon, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import { __, s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -10,6 +11,8 @@ export default { ClipboardButton, DeploymentStatusBadge, GlBadge, + GlButton, + GlCollapse, GlIcon, TimeAgoTooltip, }, @@ -27,6 +30,9 @@ export default { required: false, }, }, + data() { + return { visible: false }; + }, computed: { status() { return this.deployment?.status; @@ -40,43 +46,103 @@ export default { createdAt() { return this.deployment?.createdAt; }, + isMobile() { + return !GlBreakpointInstance.isDesktop(); + }, + detailsButton() { + return this.visible + ? { text: this.$options.i18n.hideDetails, icon: 'expand-up' } + : { text: this.$options.i18n.showDetails, icon: 'expand-down' }; + }, + detailsButtonClasses() { + return this.isMobile ? 'gl-sr-only' : ''; + }, + }, + methods: { + toggleCollapse() { + this.visible = !this.visible; + }, }, i18n: { latestBadge: s__('Deployment|Latest Deployed'), deploymentId: s__('Deployment|Deployment ID'), copyButton: __('Copy commit SHA'), commitSha: __('Commit SHA'), + showDetails: __('Show details'), + hideDetails: __('Hide details'), }, + headerClasses: [ + 'gl-display-flex', + 'gl-align-items-flex-start', + 'gl-md-align-items-center', + 'gl-justify-content-space-between', + 'gl-pr-6', + ], + headerDetailsClasses: [ + 'gl-display-flex', + 'gl-flex-direction-column', + 'gl-md-flex-direction-row', + 'gl-align-items-flex-start', + 'gl-md-align-items-center', + 'gl-font-sm', + 'gl-text-gray-700', + ], + deploymentStatusClasses: [ + 'gl-display-flex', + 'gl-gap-x-3', + 'gl-mr-0', + 'gl-md-mr-5', + 'gl-mb-3', + 'gl-md-mb-0', + ], }; </script> <template> - <div class="gl-display-flex gl-align-items-center gl-gap-x-3 gl-font-sm gl-text-gray-700"> - <deployment-status-badge v-if="status" :status="status" /> - <gl-badge v-if="latest" variant="info">{{ $options.i18n.latestBadge }}</gl-badge> - <div - v-if="iid" - v-gl-tooltip - :title="$options.i18n.deploymentId" - :aria-label="$options.i18n.deploymentId" - > - <gl-icon ref="deployment-iid-icon" name="deployments" /> #{{ iid }} - </div> - <div - v-if="shortSha" - data-testid="deployment-commit-sha" - class="gl-font-monospace gl-display-flex gl-align-items-center" - > - <gl-icon ref="deployment-commit-icon" name="commit" class="gl-mr-2" /> - <span v-gl-tooltip :title="$options.i18n.commitSha">{{ shortSha }}</span> - <clipboard-button - :text="shortSha" + <div> + <div :class="$options.headerClasses"> + <div :class="$options.headerDetailsClasses"> + <div :class="$options.deploymentStatusClasses"> + <deployment-status-badge v-if="status" :status="status" /> + <gl-badge v-if="latest" variant="info">{{ $options.i18n.latestBadge }}</gl-badge> + </div> + <div class="gl-display-flex gl-align-items-center gl-gap-x-5"> + <div + v-if="iid" + v-gl-tooltip + :title="$options.i18n.deploymentId" + :aria-label="$options.i18n.deploymentId" + > + <gl-icon ref="deployment-iid-icon" name="deployments" /> #{{ iid }} + </div> + <div + v-if="shortSha" + data-testid="deployment-commit-sha" + class="gl-font-monospace gl-display-flex gl-align-items-center" + > + <gl-icon ref="deployment-commit-icon" name="commit" class="gl-mr-2" /> + <span v-gl-tooltip :title="$options.i18n.commitSha">{{ shortSha }}</span> + <clipboard-button + :text="shortSha" + category="tertiary" + :title="$options.i18n.copyButton" + size="small" + /> + </div> + <time-ago-tooltip v-if="createdAt" :time="createdAt"> + <template #default="{ timeAgo }"> <gl-icon name="calendar" /> {{ timeAgo }} </template> + </time-ago-tooltip> + </div> + </div> + <gl-button + ref="details-toggle" category="tertiary" - :title="$options.i18n.copyButton" - size="small" - /> - <time-ago-tooltip v-if="createdAt" :time="createdAt" class="gl-ml-5!"> - <template #default="{ timeAgo }"> <gl-icon name="calendar" /> {{ timeAgo }} </template> - </time-ago-tooltip> + :icon="detailsButton.icon" + :button-text-classes="detailsButtonClasses" + @click="toggleCollapse" + > + {{ detailsButton.text }} + </gl-button> </div> + <gl-collapse :visible="visible" /> </div> </template> diff --git a/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue index a7ae0213661..2795ddbbbcb 100644 --- a/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue +++ b/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue @@ -4,6 +4,7 @@ import { createAlert } from '~/flash'; import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import RunnerEditButton from '../components/runner_edit_button.vue'; +import RunnerPauseButton from '../components/runner_pause_button.vue'; import RunnerHeader from '../components/runner_header.vue'; import RunnerDetails from '../components/runner_details.vue'; import { I18N_FETCH_ERROR } from '../constants'; @@ -14,6 +15,7 @@ export default { name: 'AdminRunnerShowApp', components: { RunnerEditButton, + RunnerPauseButton, RunnerHeader, RunnerDetails, }, @@ -66,6 +68,7 @@ export default { <runner-header v-if="runner" :runner="runner"> <template #actions> <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" /> + <runner-pause-button v-if="canUpdate" :runner="runner" /> </template> </runner-header> diff --git a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue index 19f6598c62c..ae9c774f2a2 100644 --- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue @@ -1,16 +1,14 @@ <script> import { GlButton, GlButtonGroup, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { __, s__, sprintf } from '~/locale'; +import { s__, sprintf } from '~/locale'; import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql'; -import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql'; import { captureException } from '~/runner/sentry_utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerEditButton from '../runner_edit_button.vue'; +import RunnerPauseButton from '../runner_pause_button.vue'; import RunnerDeleteModal from '../runner_delete_modal.vue'; -const I18N_PAUSE = __('Pause'); -const I18N_RESUME = __('Resume'); const I18N_DELETE = s__('Runners|Delete runner'); const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted'); @@ -20,6 +18,7 @@ export default { GlButton, GlButtonGroup, RunnerEditButton, + RunnerPauseButton, RunnerDeleteModal, }, directives: { @@ -39,20 +38,6 @@ export default { }; }, computed: { - isActive() { - return this.runner.active; - }, - toggleActiveIcon() { - return this.isActive ? 'pause' : 'play'; - }, - toggleActiveTitle() { - if (this.updating) { - // Prevent a "sticky" tooltip: If this button is disabled, - // mouseout listeners don't run leaving the tooltip stuck - return ''; - } - return this.isActive ? I18N_PAUSE : I18N_RESUME; - }, deleteTitle() { if (this.deleting) { // Prevent a "sticky" tooltip: If this button is disabled, @@ -78,35 +63,6 @@ export default { }, }, methods: { - async onToggleActive() { - this.updating = true; - try { - const toggledActive = !this.runner.active; - - const { - data: { - runnerUpdate: { errors }, - }, - } = await this.$apollo.mutate({ - mutation: runnerActionsUpdateMutation, - variables: { - input: { - id: this.runner.id, - active: toggledActive, - }, - }, - }); - - if (errors && errors.length) { - throw new Error(errors.join(' ')); - } - } catch (e) { - this.onError(e); - } finally { - this.updating = false; - } - }, - async onDelete() { // Deleting stays "true" until this row is removed, // should only change back if the operation fails. @@ -162,15 +118,7 @@ export default { See https://gitlab.com/gitlab-org/gitlab/-/issues/334802 --> <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" /> - <gl-button - v-if="canUpdate" - v-gl-tooltip.hover.viewport="toggleActiveTitle" - :aria-label="toggleActiveTitle" - :icon="toggleActiveIcon" - :loading="updating" - data-testid="toggle-active-runner" - @click="onToggleActive" - /> + <runner-pause-button v-if="canUpdate" :runner="runner" :compact="true" /> <gl-button v-if="canDelete" v-gl-tooltip.hover.viewport="deleteTitle" diff --git a/app/assets/javascripts/runner/components/runner_header.vue b/app/assets/javascripts/runner/components/runner_header.vue index 295162e954a..abc07cec1ad 100644 --- a/app/assets/javascripts/runner/components/runner_header.vue +++ b/app/assets/javascripts/runner/components/runner_header.vue @@ -63,6 +63,6 @@ export default { <strong>{{ heading }}</strong> </template> </div> - <div class="gl-ml-auto"><slot name="actions"></slot></div> + <div class="gl-ml-auto gl-flex-shrink-0"><slot name="actions"></slot></div> </div> </template> diff --git a/app/assets/javascripts/runner/components/runner_pause_button.vue b/app/assets/javascripts/runner/components/runner_pause_button.vue new file mode 100644 index 00000000000..a8b259f5b90 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_pause_button.vue @@ -0,0 +1,122 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import runnerToggleActiveMutation from '~/runner/graphql/runner_toggle_active.mutation.graphql'; +import { createAlert } from '~/flash'; +import { captureException } from '~/runner/sentry_utils'; +import { I18N_PAUSE, I18N_RESUME } from '../constants'; + +export default { + name: 'RunnerPauseButton', + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runner: { + type: Object, + required: true, + }, + compact: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + updating: false, + }; + }, + computed: { + isActive() { + return this.runner.active; + }, + icon() { + return this.isActive ? 'pause' : 'play'; + }, + label() { + return this.isActive ? I18N_PAUSE : I18N_RESUME; + }, + buttonContent() { + if (this.compact) { + return null; + } + return this.label; + }, + ariaLabel() { + if (this.compact) { + return this.label; + } + return null; + }, + tooltip() { + // Only show tooltip when compact. + // Also prevent a "sticky" tooltip: If this button is + // disabled, mouseout listeners don't run leaving the tooltip stuck + if (this.compact && !this.updating) { + return this.label; + } + return ''; + }, + }, + methods: { + async onToggle() { + this.updating = true; + try { + const input = { + id: this.runner.id, + active: !this.isActive, + }; + + const { + data: { + runnerUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: runnerToggleActiveMutation, + variables: { + input, + }, + }); + + if (errors && errors.length) { + throw new Error(errors.join(' ')); + } + } catch (e) { + this.onError(e); + } finally { + this.updating = false; + } + }, + onError(error) { + const { message } = error; + createAlert({ message }); + + this.reportToSentry(error); + }, + reportToSentry(error) { + captureException({ error, component: this.$options.name }); + }, + }, +}; +</script> + +<template> + <gl-button + v-gl-tooltip.hover.viewport="tooltip" + v-bind="$attrs" + :aria-label="ariaLabel" + :icon="icon" + :loading="updating" + @click="onToggle" + v-on="$listeners" + > + <!-- + Use <template v-if> to ensure a square button is shown when compact: true. + Sending empty content will still show a distorted/rectangular button. + --> + <template v-if="buttonContent">{{ buttonContent }}</template> + </gl-button> +</template> diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js index ce8019ffaa0..ad0437d84ef 100644 --- a/app/assets/javascripts/runner/constants.js +++ b/app/assets/javascripts/runner/constants.js @@ -1,4 +1,4 @@ -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; export const RUNNER_PAGE_SIZE = 20; export const RUNNER_JOB_COUNT_LIMIT = 1000; @@ -28,6 +28,10 @@ export const I18N_STALE_RUNNER_DESCRIPTION = s__( 'Runners|No contact from this runner in over 3 months', ); +// Active flag +export const I18N_PAUSE = __('Pause'); +export const I18N_RESUME = __('Resume'); + export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects'); export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run jobs'); diff --git a/app/assets/javascripts/runner/graphql/runner_actions_update.mutation.graphql b/app/assets/javascripts/runner/graphql/runner_actions_update.mutation.graphql deleted file mode 100644 index 547cc43907c..00000000000 --- a/app/assets/javascripts/runner/graphql/runner_actions_update.mutation.graphql +++ /dev/null @@ -1,14 +0,0 @@ -#import "~/runner/graphql/runner_node.fragment.graphql" - -# Mutation for updates within the runners list via action -# buttons (play, pause, ...), loads attributes shown in the -# runner list. - -mutation runnerActionsUpdate($input: RunnerUpdateInput!) { - runnerUpdate(input: $input) { - runner { - ...RunnerNode - } - errors - } -} diff --git a/app/assets/javascripts/runner/graphql/runner_toggle_active.mutation.graphql b/app/assets/javascripts/runner/graphql/runner_toggle_active.mutation.graphql new file mode 100644 index 00000000000..9b15570dbc0 --- /dev/null +++ b/app/assets/javascripts/runner/graphql/runner_toggle_active.mutation.graphql @@ -0,0 +1,12 @@ +# Mutation executed for the pause/resume button in the +# runner list and details views. + +mutation runnerToggleActive($input: RunnerUpdateInput!) { + runnerUpdate(input: $input) { + runner { + id + active + } + errors + } +} diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue index d228f77f27d..c48c9067250 100644 --- a/app/assets/javascripts/security_configuration/components/app.vue +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -50,7 +50,7 @@ export default { TrainingProviderList, }, mixins: [glFeatureFlagsMixin()], - inject: ['projectPath'], + inject: ['projectFullPath'], props: { augmentedSecurityFeatures: { type: Array, @@ -107,14 +107,14 @@ export default { shouldShowAutoDevopsEnabledAlert() { return ( this.autoDevopsEnabled && - !this.autoDevopsEnabledAlertDismissedProjects.includes(this.projectPath) + !this.autoDevopsEnabledAlertDismissedProjects.includes(this.projectFullPath) ); }, }, methods: { dismissAutoDevopsEnabledAlert() { const dismissedProjects = new Set(this.autoDevopsEnabledAlertDismissedProjects); - dismissedProjects.add(this.projectPath); + dismissedProjects.add(this.projectFullPath); this.autoDevopsEnabledAlertDismissedProjects = Array.from(dismissedProjects); }, onError(message) { diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue index ca4596e16b3..c289df9f1f4 100644 --- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue +++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue @@ -21,10 +21,18 @@ export default { GlLink, GlSkeletonLoader, }, - inject: ['projectPath'], + inject: ['projectFullPath'], apollo: { securityTrainingProviders: { query: securityTrainingProvidersQuery, + variables() { + return { + fullPath: this.projectFullPath, + }; + }, + update({ project }) { + return project?.securityTrainingProviders; + }, error() { this.errorMessage = this.$options.i18n.providerQueryErrorMessage; }, @@ -68,7 +76,7 @@ export default { variables: { input: { enabledProviders: enabledProviderIds, - fullPath: this.projectPath, + fullPath: this.projectFullPath, }, }, }); diff --git a/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql b/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql index e0c5715ba8e..a8326bb1968 100644 --- a/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql +++ b/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql @@ -1,9 +1,12 @@ -query Query { - securityTrainingProviders @client { - name +query getSecurityTrainingProviders($fullPath: ID!) { + project(fullPath: $fullPath) { id - description - isEnabled - url + securityTrainingProviders { + name + id + description + isEnabled + url + } } } diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js index 24c0585e077..c8255a010b2 100644 --- a/app/assets/javascripts/security_configuration/index.js +++ b/app/assets/javascripts/security_configuration/index.js @@ -19,7 +19,7 @@ export const initSecurityConfiguration = (el) => { }); const { - projectPath, + projectFullPath, upgradePath, features, latestPipelinePath, @@ -38,7 +38,7 @@ export const initSecurityConfiguration = (el) => { el, apolloProvider, provide: { - projectPath, + projectFullPath, upgradePath, autoDevopsHelpPagePath, autoDevopsPath, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue index 96ee46c3b27..a25b4ab54e5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -128,10 +128,12 @@ export default { api.trackRedisHllUserEvent(this.$options.expandEvent); } }), - toggleCollapsed() { - this.isCollapsed = !this.isCollapsed; + toggleCollapsed(e) { + if (!e?.target?.closest('.btn:not(.btn-icon),a')) { + this.isCollapsed = !this.isCollapsed; - this.triggerRedisTracking(); + this.triggerRedisTracking(); + } }, initExtensionPolling() { const poll = new Poll({ @@ -207,6 +209,19 @@ export default { this.showFade = true; } }, + onRowMouseDown() { + this.down = Number(new Date()); + }, + onRowMouseUp(e) { + const up = Number(new Date()); + + // To allow for text to be selected we check if the the user is clicking + // or selecting, if they are selecting the time difference should be + // more than 200ms + if (up - this.down < 200) { + this.toggleCollapsed(e); + } + }, generateText, }, EXTENSION_ICON_CLASS, @@ -215,7 +230,7 @@ export default { <template> <section class="media-section" data-testid="widget-extension"> - <div class="media gl-p-5"> + <div class="media gl-p-5 gl-cursor-pointer" @mousedown="onRowMouseDown" @mouseup="onRowMouseUp"> <status-icon :name="$options.label || $options.name" :is-loading="isLoadingSummary" @@ -253,7 +268,7 @@ export default { category="tertiary" data-testid="toggle-button" size="small" - @click="toggleCollapsed" + @click.self="toggleCollapsed" /> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js index ba3336df2eb..4aeebf095c4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js @@ -25,9 +25,9 @@ export default { n__( 'ciReport|Load performance test metrics detected %{strong_start}%{changesFound}%{strong_end} change', 'ciReport|Load performance test metrics detected %{strong_start}%{changesFound}%{strong_end} changes', - changesFound, + count, ), - { changesFound }, + { changesFound: count }, ); }, // Status icon to be used next to the summary text diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue index d1630c9ac13..3afd1f9410b 100644 --- a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue +++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue @@ -14,7 +14,7 @@ export default { components: { GlButton, }, - inject: ['projectPath'], + inject: ['projectFullPath'], props: { feature: { type: Object, @@ -47,7 +47,7 @@ export default { try { const { mutationSettings } = this; const { data } = await this.$apollo.mutate( - mutationSettings.getMutationPayload(this.projectPath), + mutationSettings.getMutationPayload(this.projectFullPath), ); const { errors, successPath } = data[mutationSettings.mutationId]; diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb index 3d9b6e94cc6..ff948d686ee 100644 --- a/app/finders/group_descendants_finder.rb +++ b/app/finders/group_descendants_finder.rb @@ -110,8 +110,13 @@ class GroupDescendantsFinder # rubocop: disable CodeReuse/ActiveRecord def ancestors_of_groups(base_for_ancestors) group_ids = base_for_ancestors.except(:select, :sort).select(:id) - Gitlab::ObjectHierarchy.new(Group.where(id: group_ids)) - .base_and_ancestors(upto: parent_group.id) + groups = Group.where(id: group_ids) + + if Feature.enabled?(:linear_group_descendants_finder_upto, current_user, default_enabled: :yaml) + groups.self_and_ancestors(upto: parent_group.id) + else + Gitlab::ObjectHierarchy.new(groups).base_and_ancestors(upto: parent_group.id) + end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/views/projects/security/configuration/show.html.haml b/app/views/projects/security/configuration/show.html.haml index e8ac572df1d..c96d737a8b5 100644 --- a/app/views/projects/security/configuration/show.html.haml +++ b/app/views/projects/security/configuration/show.html.haml @@ -2,4 +2,4 @@ - page_title _("Security Configuration") - @content_class = "limit-container-width" unless fluid_layout -#js-security-configuration-static{ data: { project_path: @project.full_path, upgrade_path: security_upgrade_path } } +#js-security-configuration-static{ data: { project_full_path: @project.full_path, upgrade_path: security_upgrade_path } } diff --git a/app/workers/auto_devops/disable_worker.rb b/app/workers/auto_devops/disable_worker.rb index 9ec3e5490c2..03613db3f29 100644 --- a/app/workers/auto_devops/disable_worker.rb +++ b/app/workers/auto_devops/disable_worker.rb @@ -32,8 +32,12 @@ module AutoDevops def email_receivers_for(pipeline, project) recipients = [pipeline.user&.email] - recipients << project.owner.email unless project.group - recipients.uniq.compact + + if project.personal? + recipients << project.owners.map(&:email) + end + + recipients.flatten.uniq.compact end end end diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb index 4dd9a9c6fcb..e3f8c4bcd9d 100644 --- a/app/workers/project_export_worker.rb +++ b/app/workers/project_export_worker.rb @@ -24,8 +24,15 @@ class ProjectExportWorker # rubocop:disable Scalability/IdempotentWorker ::Projects::ImportExport::ExportService.new(project, current_user, params).execute(after_export) export_job&.finish - rescue ActiveRecord::RecordNotFound, Gitlab::ImportExport::AfterExportStrategyBuilder::StrategyNotFoundError => e - logger.error("Failed to export project #{project_id}: #{e.message}") + rescue ActiveRecord::RecordNotFound => e + log_failure(project_id, e) + rescue Gitlab::ImportExport::AfterExportStrategyBuilder::StrategyNotFoundError => e + log_failure(project_id, e) + export_job&.finish + rescue StandardError => e + log_failure(project_id, e) + export_job&.fail_op + raise end private @@ -35,4 +42,8 @@ class ProjectExportWorker # rubocop:disable Scalability/IdempotentWorker Gitlab::ImportExport::AfterExportStrategyBuilder.build!(strategy_klass, after_export_strategy) end + + def log_failure(project_id, ex) + logger.error("Failed to export project #{project_id}: #{ex.message}") + end end diff --git a/config/feature_flags/development/ci_order_subsequent_jobs_by_stage.yml b/config/feature_flags/development/ci_order_subsequent_jobs_by_stage.yml index dfc4ab3bad3..babe10764e3 100644 --- a/config/feature_flags/development/ci_order_subsequent_jobs_by_stage.yml +++ b/config/feature_flags/development/ci_order_subsequent_jobs_by_stage.yml @@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/349977 milestone: '14.7' type: development group: group::pipeline authoring -default_enabled: false +default_enabled: true diff --git a/config/feature_flags/development/export_reduce_relation_batch_size.yml b/config/feature_flags/development/linear_group_descendants_finder_upto.yml index 63164b6e9fe..ef045ebfa7a 100644 --- a/config/feature_flags/development/export_reduce_relation_batch_size.yml +++ b/config/feature_flags/development/linear_group_descendants_finder_upto.yml @@ -1,8 +1,8 @@ --- -name: export_reduce_relation_batch_size -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/34057 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/282245 -milestone: '13.1' +name: linear_group_descendants_finder_upto +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/78991 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/350972 +milestone: '14.8' type: development -group: group::import +group: group::authentication and authorization default_enabled: false diff --git a/db/migrate/20210316171009_create_packages_helm_file_metadata.rb b/db/migrate/20210316171009_create_packages_helm_file_metadata.rb index f5a9c5f1146..47a9b808e63 100644 --- a/db/migrate/20210316171009_create_packages_helm_file_metadata.rb +++ b/db/migrate/20210316171009_create_packages_helm_file_metadata.rb @@ -5,7 +5,7 @@ class CreatePackagesHelmFileMetadata < ActiveRecord::Migration[6.0] DOWNTIME = false - def change + def up create_table_with_constraints :packages_helm_file_metadata, id: false do |t| t.timestamps_with_timezone t.references :package_file, primary_key: true, index: false, default: nil, null: false, foreign_key: { to_table: :packages_package_files, on_delete: :cascade }, type: :bigint @@ -17,4 +17,10 @@ class CreatePackagesHelmFileMetadata < ActiveRecord::Migration[6.0] t.index :channel end end + + def down + with_lock_retries do + drop_table :packages_helm_file_metadata + end + end end diff --git a/db/migrate/20210504153354_create_clusters_integration_elasticstack.rb b/db/migrate/20210504153354_create_clusters_integration_elasticstack.rb index 5868325e701..79680821060 100644 --- a/db/migrate/20210504153354_create_clusters_integration_elasticstack.rb +++ b/db/migrate/20210504153354_create_clusters_integration_elasticstack.rb @@ -3,7 +3,7 @@ class CreateClustersIntegrationElasticstack < ActiveRecord::Migration[6.0] include Gitlab::Database::MigrationHelpers - def change + def up create_table_with_constraints :clusters_integration_elasticstack, id: false do |t| t.timestamps_with_timezone null: false t.references :cluster, primary_key: true, default: nil, index: false, foreign_key: { on_delete: :cascade } @@ -12,4 +12,10 @@ class CreateClustersIntegrationElasticstack < ActiveRecord::Migration[6.0] t.text_limit :chart_version, 10 end end + + def down + with_lock_retries do + drop_table :clusters_integration_elasticstack + end + end end diff --git a/db/migrate/20210628154900_create_detached_partitions_table.rb b/db/migrate/20210628154900_create_detached_partitions_table.rb index 05290f4dfb9..cf31d71835a 100644 --- a/db/migrate/20210628154900_create_detached_partitions_table.rb +++ b/db/migrate/20210628154900_create_detached_partitions_table.rb @@ -3,7 +3,7 @@ class CreateDetachedPartitionsTable < ActiveRecord::Migration[6.1] include Gitlab::Database::MigrationHelpers - def change + def up create_table_with_constraints :detached_partitions do |t| t.timestamps_with_timezone null: false t.datetime_with_timezone :drop_after, null: false @@ -14,4 +14,10 @@ class CreateDetachedPartitionsTable < ActiveRecord::Migration[6.1] t.text_limit :table_name, 63 end end + + def down + with_lock_retries do + drop_table :detached_partitions + end + end end diff --git a/db/migrate/20210720140841_create_postgres_async_indexes_table.rb b/db/migrate/20210720140841_create_postgres_async_indexes_table.rb index 707367e5f6c..99025149840 100644 --- a/db/migrate/20210720140841_create_postgres_async_indexes_table.rb +++ b/db/migrate/20210720140841_create_postgres_async_indexes_table.rb @@ -3,7 +3,7 @@ class CreatePostgresAsyncIndexesTable < ActiveRecord::Migration[6.1] include Gitlab::Database::MigrationHelpers - def change + def up create_table_with_constraints :postgres_async_indexes do |t| t.timestamps_with_timezone null: false @@ -18,4 +18,10 @@ class CreatePostgresAsyncIndexesTable < ActiveRecord::Migration[6.1] t.index :name, unique: true end end + + def down + with_lock_retries do + drop_table :postgres_async_indexes + end + end end diff --git a/db/migrate/20210729081351_create_topics.rb b/db/migrate/20210729081351_create_topics.rb index c6fdc6bb98a..13ed2dc7ccc 100644 --- a/db/migrate/20210729081351_create_topics.rb +++ b/db/migrate/20210729081351_create_topics.rb @@ -3,7 +3,7 @@ class CreateTopics < ActiveRecord::Migration[6.1] include Gitlab::Database::MigrationHelpers - def change + def up create_table_with_constraints :topics do |t| t.text :name, null: false t.text_limit :name, 255 @@ -13,4 +13,10 @@ class CreateTopics < ActiveRecord::Migration[6.1] t.timestamps_with_timezone end end + + def down + with_lock_retries do + drop_table :topics + end + end end diff --git a/db/post_migrate/20220126201752_remove_projects_ci_job_token_project_scope_links_target_project_id_fk.rb b/db/post_migrate/20220126201752_remove_projects_ci_job_token_project_scope_links_target_project_id_fk.rb new file mode 100644 index 00000000000..a33e02f2408 --- /dev/null +++ b/db/post_migrate/20220126201752_remove_projects_ci_job_token_project_scope_links_target_project_id_fk.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class RemoveProjectsCiJobTokenProjectScopeLinksTargetProjectIdFk < Gitlab::Database::Migration[1.0] + disable_ddl_transaction! + + def up + return unless foreign_key_exists?(:ci_job_token_project_scope_links, :projects, name: "fk_rails_6904b38465") + + with_lock_retries do + execute('LOCK projects, ci_job_token_project_scope_links IN ACCESS EXCLUSIVE MODE') if transaction_open? + + remove_foreign_key_if_exists(:ci_job_token_project_scope_links, :projects, name: "fk_rails_6904b38465") + end + end + + def down + add_concurrent_foreign_key(:ci_job_token_project_scope_links, :projects, name: "fk_rails_6904b38465", column: :target_project_id, target_column: :id, on_delete: :cascade) + end +end diff --git a/db/post_migrate/20220126210021_remove_projects_ci_builds_project_id_fk.rb b/db/post_migrate/20220126210021_remove_projects_ci_builds_project_id_fk.rb new file mode 100644 index 00000000000..8caa6db7507 --- /dev/null +++ b/db/post_migrate/20220126210021_remove_projects_ci_builds_project_id_fk.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class RemoveProjectsCiBuildsProjectIdFk < Gitlab::Database::Migration[1.0] + disable_ddl_transaction! + + def up + return if Gitlab.com? # unsafe migration, skip on GitLab.com due to https://gitlab.com/groups/gitlab-org/-/epics/7249#note_819625526 + return unless foreign_key_exists?(:ci_builds, :projects, name: "fk_befce0568a") + + with_lock_retries do + execute('LOCK projects, ci_builds IN ACCESS EXCLUSIVE MODE') if transaction_open? + + remove_foreign_key_if_exists(:ci_builds, :projects, name: "fk_befce0568a") + end + end + + def down + add_concurrent_foreign_key(:ci_builds, :projects, name: "fk_befce0568a", column: :project_id, target_column: :id, on_delete: :cascade) + end +end diff --git a/db/schema_migrations/20220126201752 b/db/schema_migrations/20220126201752 new file mode 100644 index 00000000000..e5a1970ec9e --- /dev/null +++ b/db/schema_migrations/20220126201752 @@ -0,0 +1 @@ +7731772dfac065a60c1626707913ddf6ff632bb69dd5ed6534e8d29e4e03c573
\ No newline at end of file diff --git a/db/schema_migrations/20220126210021 b/db/schema_migrations/20220126210021 new file mode 100644 index 00000000000..edb9d9c779d --- /dev/null +++ b/db/schema_migrations/20220126210021 @@ -0,0 +1 @@ +fd7940bb6f077c91d7f9928f574443ba4bf33bb90cb702c0a2ecad14398ab1cc
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 14da63d4b94..9e40580d3f5 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -29753,9 +29753,6 @@ ALTER TABLE ONLY ci_sources_pipelines ALTER TABLE ONLY packages_maven_metadata ADD CONSTRAINT fk_be88aed360 FOREIGN KEY (package_id) REFERENCES packages_packages(id) ON DELETE CASCADE; -ALTER TABLE ONLY ci_builds - ADD CONSTRAINT fk_befce0568a FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; - ALTER TABLE ONLY design_management_versions ADD CONSTRAINT fk_c1440b4896 FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL; @@ -30650,9 +30647,6 @@ ALTER TABLE ONLY resource_iteration_events ALTER TABLE ONLY geo_hashed_storage_migrated_events ADD CONSTRAINT fk_rails_687ed7d7c5 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; -ALTER TABLE ONLY ci_job_token_project_scope_links - ADD CONSTRAINT fk_rails_6904b38465 FOREIGN KEY (target_project_id) REFERENCES projects(id) ON DELETE CASCADE; - ALTER TABLE ONLY plan_limits ADD CONSTRAINT fk_rails_69f8b6184f FOREIGN KEY (plan_id) REFERENCES plans(id) ON DELETE CASCADE; diff --git a/doc/integration/img/enable_trello_powerup.png b/doc/integration/img/enable_trello_powerup.png Binary files differdeleted file mode 100644 index f80d0eadc0b..00000000000 --- a/doc/integration/img/enable_trello_powerup.png +++ /dev/null diff --git a/doc/integration/trello_power_up.md b/doc/integration/trello_power_up.md index 96150440e53..8a8952cb594 100644 --- a/doc/integration/trello_power_up.md +++ b/doc/integration/trello_power_up.md @@ -6,44 +6,25 @@ info: To determine the technical writer assigned to the Stage/Group associated w # Trello Power-Up **(FREE)** -The GitLab Trello Power-Up enables you to seamlessly attach -GitLab **merge requests** to Trello cards. +You can use the Trello Power-Up for GitLab to attach +GitLab merge requests to Trello cards. ![GitLab Trello PowerUp - Trello card](img/trello_card_with_gitlab_powerup.png) -## Configuring the Power-Up +## Configure the Power-Up -In order to get started, you must configure your Power-Up. +To configure a Power-Up for a Trello board: -In Trello: +1. Go to your Trello board. +1. Select **Power-Ups** and find the **GitLab** row. +1. Select **Enable**. +1. Select **Settings** (the gear icon). +1. Select **Authorize Account**. +1. Enter the [GitLab API URL](#get-the-api-url) and [personal access token](../user/profile/personal_access_tokens.md#create-a-personal-access-token) with the **API** scope. +1. Select **Save**. -1. Go to your Trello board -1. Select `Power-Ups` to see a listing of all the available Power-Ups -1. Look for a row that says `GitLab` and select the `Enable` button -1. Select the `Settings` (gear) icon -1. In the popup menu, select `Authorize Account` +## Get the API URL -In this popup, fill in your `API URL` and `Personal Access Token`. After that, you can attach any merge request to any Trello card on your selected Trello board. - -## What is my API URL? - -Your API URL should be your GitLab instance URL with `/api/v4` appended in the end of the URL. -For example, if your GitLab instance URL is `https://gitlab.com`, your API URL would be `https://gitlab.com/api/v4`. -If your instance's URL is `https://example.com`, your API URL is `https://example.com/api/v4`. - -![configure GitLab Trello PowerUp in Trello](img/enable_trello_powerup.png) - -## What is my Personal Access Token? - -Your GitLab personal access token enables your GitLab account to be accessed -from Trello. - -To find it in GitLab: - -1. In the top-right corner, select your avatar. -1. Select **Edit profile**. -1. On the left sidebar, select **Access Tokens**. - -Learn more about generating a personal access token in the -[Personal Access Token Documentation](../user/profile/personal_access_tokens.md). -Don't forget to check the API scope checkbox! +Your API URL is your GitLab instance URL with `/api/v4` appended at the end of the URL. +For example, if your GitLab instance URL is `https://gitlab.com`, your API URL is `https://gitlab.com/api/v4`. +If your instance URL is `https://example.com`, your API URL is `https://example.com/api/v4`. diff --git a/lib/gitlab/database/gitlab_loose_foreign_keys.yml b/lib/gitlab/database/gitlab_loose_foreign_keys.yml index e7cf19f5e7e..531b9f58a48 100644 --- a/lib/gitlab/database/gitlab_loose_foreign_keys.yml +++ b/lib/gitlab/database/gitlab_loose_foreign_keys.yml @@ -42,6 +42,9 @@ ci_job_token_project_scope_links: - table: projects column: source_project_id on_delete: async_delete + - table: projects + column: target_project_id + on_delete: async_delete ci_daily_build_group_report_results: - table: namespaces column: group_id @@ -88,6 +91,9 @@ ci_builds: - table: users column: user_id on_delete: async_nullify + - table: projects + column: project_id + on_delete: async_delete ci_pipelines: - table: merge_requests column: merge_request_id diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index a2fa2c33a39..23f8a44d2d2 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -429,6 +429,7 @@ module Gitlab def with_lock_retries(*args, **kwargs, &block) raise_on_exhaustion = !!kwargs.delete(:raise_on_exhaustion) merged_args = { + connection: connection, klass: self.class, logger: Gitlab::BackgroundMigration::Logger, allow_savepoints: true diff --git a/lib/gitlab/database/migrations/lock_retry_mixin.rb b/lib/gitlab/database/migrations/lock_retry_mixin.rb index fff0f35e33c..9774797676a 100644 --- a/lib/gitlab/database/migrations/lock_retry_mixin.rb +++ b/lib/gitlab/database/migrations/lock_retry_mixin.rb @@ -9,6 +9,10 @@ module Gitlab migration.class end + def migration_connection + migration.connection + end + def enable_lock_retries? # regular AR migrations don't have this, # only ones inheriting from Gitlab::Database::Migration have @@ -24,6 +28,7 @@ module Gitlab def ddl_transaction(migration, &block) if use_transaction?(migration) && migration.enable_lock_retries? Gitlab::Database::WithLockRetries.new( + connection: migration.migration_connection, klass: migration.migration_class, logger: Gitlab::BackgroundMigration::Logger ).run(raise_on_exhaustion: false, &block) diff --git a/lib/gitlab/database/schema_helpers.rb b/lib/gitlab/database/schema_helpers.rb index 9ddc5391689..f96de13006f 100644 --- a/lib/gitlab/database/schema_helpers.rb +++ b/lib/gitlab/database/schema_helpers.rb @@ -73,6 +73,7 @@ module Gitlab def with_lock_retries(&block) Gitlab::Database::WithLockRetries.new( + connection: connection, klass: self.class, logger: Gitlab::BackgroundMigration::Logger ).run(&block) diff --git a/lib/gitlab/database/with_lock_retries.rb b/lib/gitlab/database/with_lock_retries.rb index f9d467ae5cc..f2c5bb9088f 100644 --- a/lib/gitlab/database/with_lock_retries.rb +++ b/lib/gitlab/database/with_lock_retries.rb @@ -61,7 +61,7 @@ module Gitlab [10.seconds, 10.minutes] ].freeze - def initialize(logger: NULL_LOGGER, allow_savepoints: true, timing_configuration: DEFAULT_TIMING_CONFIGURATION, klass: nil, env: ENV, connection: ActiveRecord::Base.connection) + def initialize(logger: NULL_LOGGER, allow_savepoints: true, timing_configuration: DEFAULT_TIMING_CONFIGURATION, klass: nil, env: ENV, connection:) @logger = logger @klass = klass @allow_savepoints = allow_savepoints diff --git a/lib/gitlab/import_export/json/streaming_serializer.rb b/lib/gitlab/import_export/json/streaming_serializer.rb index fb8d6e7d89b..d893c8dfaa3 100644 --- a/lib/gitlab/import_export/json/streaming_serializer.rb +++ b/lib/gitlab/import_export/json/streaming_serializer.rb @@ -6,15 +6,10 @@ module Gitlab class StreamingSerializer include Gitlab::ImportExport::CommandLineUtil - BATCH_SIZE = 100 - SMALLER_BATCH_SIZE = 2 + BATCH_SIZE = 2 def self.batch_size(exportable) - if Feature.enabled?(:export_reduce_relation_batch_size, exportable) - SMALLER_BATCH_SIZE - else - BATCH_SIZE - end + BATCH_SIZE end class Raw < String diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 61088c3156d..73eb07d34fc 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -27983,10 +27983,13 @@ msgstr "" msgid "ProjectSettings|Checkbox is visible and unselected by default." msgstr "" +msgid "ProjectSettings|Choose the method, options, checks, and squash options for merge requests. You can also set up merge request templates for different actions." +msgstr "" + msgid "ProjectSettings|Choose your merge method, merge options, merge checks, and merge suggestions." msgstr "" -msgid "ProjectSettings|Choose your merge method, merge options, merge checks, merge suggestions, and set up a default description template for merge requests." +msgid "ProjectSettings|Choose your merge method, options, checks, and squash options." msgstr "" msgid "ProjectSettings|Configure your project resources and monitor their health." @@ -33292,6 +33295,9 @@ msgstr "" msgid "SlackIntegration|GitLab for Slack" msgstr "" +msgid "SlackIntegration|GitLab for Slack was successfully installed." +msgstr "" + msgid "SlackIntegration|Project alias" msgstr "" @@ -33307,6 +33313,9 @@ msgstr "" msgid "SlackIntegration|To set up this integration press \"Add to Slack\"" msgstr "" +msgid "SlackIntegration|You can now close this window and go to your Slack workspace." +msgstr "" + msgid "SlackService|1. %{slash_command_link_start}Add a slash command%{slash_command_link_end} in your Slack team using this information:" msgstr "" @@ -34432,6 +34441,27 @@ msgstr "" msgid "SubscriptionBanner|Upload new license" msgstr "" +msgid "SubscriptionEmail|%{doc_link_start}Please reach out if you have questions%{doc_link_end}, and we'll be happy to assist." +msgstr "" + +msgid "SubscriptionEmail|Additional charges for your GitLab subscription" +msgstr "" + +msgid "SubscriptionEmail|Dear %{customer_name}," +msgstr "" + +msgid "SubscriptionEmail|GitLab Billing Team" +msgstr "" + +msgid "SubscriptionEmail|Thank you for your business!" +msgstr "" + +msgid "SubscriptionEmail|You can find more information about the quarterly reconciliation process in %{doc_link_start}our documentation%{doc_link_end}." +msgstr "" + +msgid "SubscriptionEmail|You have exceeded the number of seats in your GitLab subscription %{subscription_name} by %{seat_quantity}. Even if you've exceeded the seats in your subscription, you can continue to add users, and GitLab will bill you a prorated amount for any seat overages on a quarterly basis." +msgstr "" + msgid "SubscriptionTable|Add seats" msgstr "" diff --git a/spec/finders/group_descendants_finder_spec.rb b/spec/finders/group_descendants_finder_spec.rb index 59eeb078e9e..26fa5544f3b 100644 --- a/spec/finders/group_descendants_finder_spec.rb +++ b/spec/finders/group_descendants_finder_spec.rb @@ -165,8 +165,8 @@ RSpec.describe GroupDescendantsFinder do end context 'with nested groups' do - let!(:project) { create(:project, namespace: group) } - let!(:subgroup) { create(:group, :private, parent: group) } + let_it_be(:project) { create(:project, namespace: group) } + let_it_be_with_reload(:subgroup) { create(:group, :private, parent: group) } describe '#execute' do it 'contains projects and subgroups' do @@ -208,57 +208,69 @@ RSpec.describe GroupDescendantsFinder do context 'with a filter' do let(:params) { { filter: 'test' } } - it 'contains only matching projects and subgroups' do - matching_project = create(:project, namespace: group, name: 'Testproject') - matching_subgroup = create(:group, name: 'testgroup', parent: group) + shared_examples 'filter examples' do + it 'contains only matching projects and subgroups' do + matching_project = create(:project, namespace: group, name: 'Testproject') + matching_subgroup = create(:group, name: 'testgroup', parent: group) - expect(finder.execute).to contain_exactly(matching_subgroup, matching_project) - end + expect(finder.execute).to contain_exactly(matching_subgroup, matching_project) + end - it 'does not include subgroups the user does not have access to' do - _invisible_subgroup = create(:group, :private, parent: group, name: 'test1') - other_subgroup = create(:group, :private, parent: group, name: 'test2') - public_subgroup = create(:group, :public, parent: group, name: 'test3') - other_subsubgroup = create(:group, :private, parent: other_subgroup, name: 'test4') - other_user = create(:user) - other_subgroup.add_developer(other_user) + it 'does not include subgroups the user does not have access to' do + _invisible_subgroup = create(:group, :private, parent: group, name: 'test1') + other_subgroup = create(:group, :private, parent: group, name: 'test2') + public_subgroup = create(:group, :public, parent: group, name: 'test3') + other_subsubgroup = create(:group, :private, parent: other_subgroup, name: 'test4') + other_user = create(:user) + other_subgroup.add_developer(other_user) - finder = described_class.new(current_user: other_user, - parent_group: group, - params: params) + finder = described_class.new(current_user: other_user, + parent_group: group, + params: params) - expect(finder.execute).to contain_exactly(other_subgroup, public_subgroup, other_subsubgroup) - end + expect(finder.execute).to contain_exactly(other_subgroup, public_subgroup, other_subsubgroup) + end - context 'with matching children' do - it 'includes a group that has a subgroup matching the query and its parent' do - matching_subgroup = create(:group, :private, name: 'testgroup', parent: subgroup) + context 'with matching children' do + it 'includes a group that has a subgroup matching the query and its parent' do + matching_subgroup = create(:group, :private, name: 'testgroup', parent: subgroup) - expect(finder.execute).to contain_exactly(subgroup, matching_subgroup) - end + expect(finder.execute).to contain_exactly(subgroup, matching_subgroup) + end - it 'includes the parent of a matching project' do - matching_project = create(:project, namespace: subgroup, name: 'Testproject') + it 'includes the parent of a matching project' do + matching_project = create(:project, namespace: subgroup, name: 'Testproject') - expect(finder.execute).to contain_exactly(subgroup, matching_project) - end + expect(finder.execute).to contain_exactly(subgroup, matching_project) + end + + context 'with a small page size' do + let(:params) { { filter: 'test', per_page: 1 } } + + it 'contains all the ancestors of a matching subgroup regardless the page size' do + subgroup = create(:group, :private, parent: group) + matching = create(:group, :private, name: 'testgroup', parent: subgroup) - context 'with a small page size' do - let(:params) { { filter: 'test', per_page: 1 } } + expect(finder.execute).to contain_exactly(subgroup, matching) + end + end - it 'contains all the ancestors of a matching subgroup regardless the page size' do - subgroup = create(:group, :private, parent: group) - matching = create(:group, :private, name: 'testgroup', parent: subgroup) + it 'does not include the parent itself' do + group.update!(name: 'test') - expect(finder.execute).to contain_exactly(subgroup, matching) + expect(finder.execute).not_to include(group) end end + end - it 'does not include the parent itself' do - group.update!(name: 'test') + it_behaves_like 'filter examples' - expect(finder.execute).not_to include(group) + context 'when feature flag :linear_group_descendants_finder_upto is disabled' do + before do + stub_feature_flags(linear_group_descendants_finder_upto: false) end + + it_behaves_like 'filter examples' end end end diff --git a/spec/frontend/environments/deployment_spec.js b/spec/frontend/environments/deployment_spec.js index a1b232d783e..24992711a4a 100644 --- a/spec/frontend/environments/deployment_spec.js +++ b/spec/frontend/environments/deployment_spec.js @@ -1,7 +1,9 @@ +import { GlCollapse } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import { __, s__ } from '~/locale'; -import { formatDate } from '~/lib/utils/datetime_utility'; import { useFakeDate } from 'helpers/fake_date'; +import { stubTransition } from 'helpers/stub_transition'; +import { formatDate } from '~/lib/utils/datetime_utility'; +import { __, s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import Deployment from '~/environments/components/deployment.vue'; import DeploymentStatusBadge from '~/environments/components/deployment_status_badge.vue'; @@ -19,6 +21,7 @@ describe('~/environments/components/deployment.vue', () => { deployment, ...propsData, }, + stubs: { transition: stubTransition() }, }); afterEach(() => { @@ -148,4 +151,29 @@ describe('~/environments/components/deployment.vue', () => { }); }); }); + + describe('collapse', () => { + let collapse; + let button; + + beforeEach(() => { + wrapper = createWrapper(); + collapse = wrapper.findComponent(GlCollapse); + button = wrapper.findComponent({ ref: 'details-toggle' }); + }); + + it('is collapsed by default', () => { + expect(collapse.attributes('visible')).toBeUndefined(); + expect(button.props('icon')).toBe('expand-down'); + expect(button.text()).toBe(__('Show details')); + }); + + it('opens on click', async () => { + await button.trigger('click'); + + expect(button.text()).toBe(__('Hide details')); + expect(button.props('icon')).toBe('expand-up'); + expect(collapse.attributes('visible')).toBe('visible'); + }); + }); }); diff --git a/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js b/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js index 61a4f7a443c..9a02e9057c2 100644 --- a/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js +++ b/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js @@ -8,6 +8,8 @@ import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerHeader from '~/runner/components/runner_header.vue'; import RunnerDetails from '~/runner/components/runner_details.vue'; +import RunnerPauseButton from '~/runner/components/runner_pause_button.vue'; +import RunnerEditButton from '~/runner/components/runner_edit_button.vue'; import getRunnerQuery from '~/runner/graphql/get_runner.query.graphql'; import AdminRunnerShowApp from '~/runner/admin_runner_show/admin_runner_show_app.vue'; import { captureException } from '~/runner/sentry_utils'; @@ -17,7 +19,8 @@ import { runnerData } from '../mock_data'; jest.mock('~/flash'); jest.mock('~/runner/sentry_utils'); -const mockRunnerGraphqlId = runnerData.data.runner.id; +const mockRunner = runnerData.data.runner; +const mockRunnerGraphqlId = mockRunner.id; const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`; Vue.use(VueApollo); @@ -28,6 +31,16 @@ describe('AdminRunnerShowApp', () => { const findRunnerHeader = () => wrapper.findComponent(RunnerHeader); const findRunnerDetails = () => wrapper.findComponent(RunnerDetails); + const findRunnerEditButton = () => wrapper.findComponent(RunnerEditButton); + const findRunnerPauseButton = () => wrapper.findComponent(RunnerPauseButton); + + const mockRunnerQueryResult = (runner = {}) => { + mockRunnerQuery = jest.fn().mockResolvedValue({ + data: { + runner: { ...mockRunner, ...runner }, + }, + }); + }; const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { wrapper = mountFn(AdminRunnerShowApp, { @@ -41,10 +54,6 @@ describe('AdminRunnerShowApp', () => { return waitForPromises(); }; - beforeEach(() => { - mockRunnerQuery = jest.fn().mockResolvedValue(runnerData); - }); - afterEach(() => { mockRunnerQuery.mockReset(); wrapper.destroy(); @@ -52,6 +61,8 @@ describe('AdminRunnerShowApp', () => { describe('When showing runner details', () => { beforeEach(async () => { + mockRunnerQueryResult(); + await createComponent({ mountFn: mount }); }); @@ -63,6 +74,11 @@ describe('AdminRunnerShowApp', () => { expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`); }); + it('displays the runner edit and pause buttons', async () => { + expect(findRunnerEditButton().exists()).toBe(true); + expect(findRunnerPauseButton().exists()).toBe(true); + }); + it('shows basic runner details', async () => { const expected = `Details Description Instance runner @@ -75,6 +91,42 @@ describe('AdminRunnerShowApp', () => { expect(findRunnerDetails().text()).toMatchInterpolatedText(expected); }); + + describe('when runner cannot be updated', () => { + beforeEach(async () => { + mockRunnerQueryResult({ + userPermissions: { + updateRunner: false, + }, + }); + + await createComponent({ + mountFn: mount, + }); + }); + + it('does not display the runner edit and pause buttons', () => { + expect(findRunnerEditButton().exists()).toBe(false); + expect(findRunnerPauseButton().exists()).toBe(false); + }); + }); + + describe('when runner does not have an edit url ', () => { + beforeEach(async () => { + mockRunnerQueryResult({ + editAdminUrl: null, + }); + + await createComponent({ + mountFn: mount, + }); + }); + + it('does not display the runner edit button', () => { + expect(findRunnerEditButton().exists()).toBe(false); + expect(findRunnerPauseButton().exists()).toBe(true); + }); + }); }); describe('When there is an error', () => { diff --git a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js index 69838ab95fa..b2c740c3f27 100644 --- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js @@ -9,12 +9,12 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { captureException } from '~/runner/sentry_utils'; import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue'; +import RunnerPauseButton from '~/runner/components/runner_pause_button.vue'; import RunnerEditButton from '~/runner/components/runner_edit_button.vue'; import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue'; import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql'; import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql'; -import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql'; import { runnersData } from '../../mock_data'; const mockRunner = runnersData.data.runners.nodes[0]; @@ -32,10 +32,9 @@ describe('RunnerTypeCell', () => { const mockToastShow = jest.fn(); const runnerDeleteMutationHandler = jest.fn(); - const runnerActionsUpdateMutationHandler = jest.fn(); const findEditBtn = () => wrapper.findComponent(RunnerEditButton); - const findToggleActiveBtn = () => wrapper.findByTestId('toggle-active-runner'); + const findRunnerPauseBtn = () => wrapper.findComponent(RunnerPauseButton); const findRunnerDeleteModal = () => wrapper.findComponent(RunnerDeleteModal); const findDeleteBtn = () => wrapper.findByTestId('delete-runner'); const getTooltip = (w) => getBinding(w.element, 'gl-tooltip')?.value; @@ -52,10 +51,7 @@ describe('RunnerTypeCell', () => { ...runner, }, }, - apolloProvider: createMockApollo([ - [runnerDeleteMutation, runnerDeleteMutationHandler], - [runnerActionsUpdateMutation, runnerActionsUpdateMutationHandler], - ]), + apolloProvider: createMockApollo([[runnerDeleteMutation, runnerDeleteMutationHandler]]), directives: { GlTooltip: createMockDirective(), GlModal: createMockDirective(), @@ -77,21 +73,11 @@ describe('RunnerTypeCell', () => { }, }, }); - - runnerActionsUpdateMutationHandler.mockResolvedValue({ - data: { - runnerUpdate: { - runner: mockRunner, - errors: [], - }, - }, - }); }); afterEach(() => { mockToastShow.mockReset(); runnerDeleteMutationHandler.mockReset(); - runnerActionsUpdateMutationHandler.mockReset(); wrapper.destroy(); }); @@ -123,118 +109,14 @@ describe('RunnerTypeCell', () => { }); }); - describe('Toggle active action', () => { - describe.each` - state | label | icon | isActive | newActiveValue - ${'active'} | ${'Pause'} | ${'pause'} | ${true} | ${false} - ${'paused'} | ${'Resume'} | ${'play'} | ${false} | ${true} - `('When the runner is $state', ({ label, icon, isActive, newActiveValue }) => { - beforeEach(() => { - createComponent({ active: isActive }); - }); - - it(`Displays a ${icon} button`, () => { - expect(findToggleActiveBtn().props('loading')).toBe(false); - expect(findToggleActiveBtn().props('icon')).toBe(icon); - expect(getTooltip(findToggleActiveBtn())).toBe(label); - expect(findToggleActiveBtn().attributes('aria-label')).toBe(label); - }); - - it(`After clicking the ${icon} button, the button has a loading state`, async () => { - await findToggleActiveBtn().vm.$emit('click'); - - expect(findToggleActiveBtn().props('loading')).toBe(true); - }); - - it(`After the ${icon} button is clicked, stale tooltip is removed`, async () => { - await findToggleActiveBtn().vm.$emit('click'); - - expect(getTooltip(findToggleActiveBtn())).toBe(''); - expect(findToggleActiveBtn().attributes('aria-label')).toBe(''); - }); - - describe(`When clicking on the ${icon} button`, () => { - it(`The apollo mutation to set active to ${newActiveValue} is called`, async () => { - expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(0); - - await findToggleActiveBtn().vm.$emit('click'); - - expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(1); - expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledWith({ - input: { - id: mockRunner.id, - active: newActiveValue, - }, - }); - }); - - it('The button does not have a loading state after the mutation occurs', async () => { - await findToggleActiveBtn().vm.$emit('click'); - - expect(findToggleActiveBtn().props('loading')).toBe(true); - - await waitForPromises(); - - expect(findToggleActiveBtn().props('loading')).toBe(false); - }); - }); - - describe('When update fails', () => { - describe('On a network error', () => { - const mockErrorMsg = 'Update error!'; - - beforeEach(async () => { - runnerActionsUpdateMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); - - findToggleActiveBtn().vm.$emit('click'); - await waitForPromises(); - }); - - it('error is reported to sentry', () => { - expect(captureException).toHaveBeenCalledWith({ - error: new Error(`Network error: ${mockErrorMsg}`), - component: 'RunnerActionsCell', - }); - }); - - it('error is shown to the user', () => { - expect(createAlert).toHaveBeenCalledTimes(1); - }); - }); - - describe('On a validation error', () => { - const mockErrorMsg = 'Runner not found!'; - const mockErrorMsg2 = 'User not allowed!'; - - beforeEach(async () => { - runnerActionsUpdateMutationHandler.mockResolvedValue({ - data: { - runnerUpdate: { - runner: mockRunner, - errors: [mockErrorMsg, mockErrorMsg2], - }, - }, - }); - - findToggleActiveBtn().vm.$emit('click'); - await waitForPromises(); - }); - - it('error is reported to sentry', () => { - expect(captureException).toHaveBeenCalledWith({ - error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`), - component: 'RunnerActionsCell', - }); - }); + describe('Pause action', () => { + it('Renders a compact pause button', () => { + createComponent(); - it('error is shown to the user', () => { - expect(createAlert).toHaveBeenCalledTimes(1); - }); - }); - }); + expect(findRunnerPauseBtn().props('compact')).toBe(true); }); - it('Does not render the runner toggle active button when user cannot update', () => { + it('Does not render the runner pause button when user cannot update', () => { createComponent({ userPermissions: { ...mockRunner.userPermissions, @@ -242,7 +124,7 @@ describe('RunnerTypeCell', () => { }, }); - expect(findToggleActiveBtn().exists()).toBe(false); + expect(findRunnerPauseBtn().exists()).toBe(false); }); }); diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js index 13f53c36785..c50486b6f7b 100644 --- a/spec/frontend/runner/components/runner_list_spec.js +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -7,6 +7,7 @@ import { import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerList from '~/runner/components/runner_list.vue'; import RunnerEditButton from '~/runner/components/runner_edit_button.vue'; +import RunnerPauseButton from '~/runner/components/runner_pause_button.vue'; import { runnersData } from '../mock_data'; const mockRunners = runnersData.data.runners.nodes; @@ -92,7 +93,8 @@ describe('RunnerList', () => { const actions = findCell({ fieldKey: 'actions' }); expect(actions.findComponent(RunnerEditButton).exists()).toBe(true); - expect(actions.findByTestId('toggle-active-runner').exists()).toBe(true); + expect(actions.findComponent(RunnerPauseButton).exists()).toBe(true); + expect(actions.findByTestId('delete-runner').exists()).toBe(true); }); describe('Table data formatting', () => { diff --git a/spec/frontend/runner/components/runner_pause_button_spec.js b/spec/frontend/runner/components/runner_pause_button_spec.js new file mode 100644 index 00000000000..34510883a25 --- /dev/null +++ b/spec/frontend/runner/components/runner_pause_button_spec.js @@ -0,0 +1,239 @@ +import Vue from 'vue'; +import { GlButton } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; +import runnerToggleActiveMutation from '~/runner/graphql/runner_toggle_active.mutation.graphql'; +import waitForPromises from 'helpers/wait_for_promises'; +import { captureException } from '~/runner/sentry_utils'; +import { createAlert } from '~/flash'; + +import RunnerPauseButton from '~/runner/components/runner_pause_button.vue'; +import { runnersData } from '../mock_data'; + +const mockRunner = runnersData.data.runners.nodes[0]; + +Vue.use(VueApollo); + +jest.mock('~/flash'); +jest.mock('~/runner/sentry_utils'); + +describe('RunnerPauseButton', () => { + let wrapper; + let runnerToggleActiveHandler; + + const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value; + const findBtn = () => wrapper.findComponent(GlButton); + + const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => { + const { runner, ...propsData } = props; + + wrapper = mountFn(RunnerPauseButton, { + propsData: { + runner: { + id: mockRunner.id, + active: mockRunner.active, + ...runner, + }, + ...propsData, + }, + apolloProvider: createMockApollo([[runnerToggleActiveMutation, runnerToggleActiveHandler]]), + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + const clickAndWait = async () => { + findBtn().vm.$emit('click'); + await waitForPromises(); + }; + + beforeEach(() => { + runnerToggleActiveHandler = jest.fn().mockImplementation(({ input }) => { + return Promise.resolve({ + data: { + runnerUpdate: { + runner: { + id: input.id, + active: input.active, + }, + errors: [], + }, + }, + }); + }); + + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('Pause/Resume action', () => { + describe.each` + runnerState | icon | content | isActive | newActiveValue + ${'paused'} | ${'play'} | ${'Resume'} | ${false} | ${true} + ${'active'} | ${'pause'} | ${'Pause'} | ${true} | ${false} + `('When the runner is $runnerState', ({ icon, content, isActive, newActiveValue }) => { + beforeEach(() => { + createComponent({ + props: { + runner: { + active: isActive, + }, + }, + }); + }); + + it(`Displays a ${icon} button`, () => { + expect(findBtn().props('loading')).toBe(false); + expect(findBtn().props('icon')).toBe(icon); + expect(findBtn().text()).toBe(content); + }); + + it('Does not display redundant text for screen readers', () => { + expect(findBtn().attributes('aria-label')).toBe(undefined); + }); + + describe(`Before the ${icon} button is clicked`, () => { + it('The mutation has not been called', () => { + expect(runnerToggleActiveHandler).toHaveBeenCalledTimes(0); + }); + }); + + describe(`Immediately after the ${icon} button is clicked`, () => { + beforeEach(async () => { + findBtn().vm.$emit('click'); + }); + + it('The button has a loading state', async () => { + expect(findBtn().props('loading')).toBe(true); + }); + + it('The stale tooltip is removed', async () => { + expect(getTooltip()).toBe(''); + }); + }); + + describe(`After clicking on the ${icon} button`, () => { + beforeEach(async () => { + await clickAndWait(); + }); + + it(`The mutation to that sets active to ${newActiveValue} is called`, async () => { + expect(runnerToggleActiveHandler).toHaveBeenCalledTimes(1); + expect(runnerToggleActiveHandler).toHaveBeenCalledWith({ + input: { + id: mockRunner.id, + active: newActiveValue, + }, + }); + }); + + it('The button does not have a loading state', () => { + expect(findBtn().props('loading')).toBe(false); + }); + }); + + describe('When update fails', () => { + describe('On a network error', () => { + const mockErrorMsg = 'Update error!'; + + beforeEach(async () => { + runnerToggleActiveHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); + + await clickAndWait(); + }); + + it('error is reported to sentry', () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error(`Network error: ${mockErrorMsg}`), + component: 'RunnerPauseButton', + }); + }); + + it('error is shown to the user', () => { + expect(createAlert).toHaveBeenCalledTimes(1); + }); + }); + + describe('On a validation error', () => { + const mockErrorMsg = 'Runner not found!'; + const mockErrorMsg2 = 'User not allowed!'; + + beforeEach(async () => { + runnerToggleActiveHandler.mockResolvedValueOnce({ + data: { + runnerUpdate: { + runner: { + id: mockRunner.id, + active: isActive, + }, + errors: [mockErrorMsg, mockErrorMsg2], + }, + }, + }); + + await clickAndWait(); + }); + + it('error is reported to sentry', () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`), + component: 'RunnerPauseButton', + }); + }); + + it('error is shown to the user', () => { + expect(createAlert).toHaveBeenCalledTimes(1); + }); + }); + }); + }); + }); + + describe('When displaying a compact button for an active runner', () => { + beforeEach(() => { + createComponent({ + props: { + runner: { + active: true, + }, + compact: true, + }, + mountFn: mountExtended, + }); + }); + + it('Displays no text', () => { + expect(findBtn().text()).toBe(''); + + // Note: Use <template v-if> to ensure rendering a + // text-less button. Ensure we don't send even empty an + // content slot to prevent a distorted/rectangular button. + expect(wrapper.find('.gl-button-text').exists()).toBe(false); + }); + + it('Display correctly for screen readers', () => { + expect(findBtn().attributes('aria-label')).toBe('Pause'); + expect(getTooltip()).toBe('Pause'); + }); + + describe('Immediately after the button is clicked', () => { + beforeEach(async () => { + findBtn().vm.$emit('click'); + }); + + it('The button has a loading state', async () => { + expect(findBtn().props('loading')).toBe(true); + }); + + it('The stale tooltip is removed', async () => { + expect(getTooltip()).toBe(''); + }); + }); + }); +}); diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js index cbdf7f53913..963577fa763 100644 --- a/spec/frontend/security_configuration/components/app_spec.js +++ b/spec/frontend/security_configuration/components/app_spec.js @@ -32,7 +32,7 @@ const upgradePath = '/upgrade'; const autoDevopsHelpPagePath = '/autoDevopsHelpPagePath'; const autoDevopsPath = '/autoDevopsPath'; const gitlabCiHistoryPath = 'test/historyPath'; -const projectPath = 'namespace/project'; +const projectFullPath = 'namespace/project'; useLocalStorageSpy(); @@ -54,7 +54,7 @@ describe('App component', () => { upgradePath, autoDevopsHelpPagePath, autoDevopsPath, - projectPath, + projectFullPath, glFeatures: { secureVulnerabilityTraining, }, @@ -274,11 +274,11 @@ describe('App component', () => { describe('Auto DevOps enabled alert', () => { describe.each` - context | autoDevopsEnabled | localStorageValue | shouldRender - ${'enabled'} | ${true} | ${null} | ${true} - ${'enabled, alert dismissed on other project'} | ${true} | ${['foo/bar']} | ${true} - ${'enabled, alert dismissed on this project'} | ${true} | ${[projectPath]} | ${false} - ${'not enabled'} | ${false} | ${null} | ${false} + context | autoDevopsEnabled | localStorageValue | shouldRender + ${'enabled'} | ${true} | ${null} | ${true} + ${'enabled, alert dismissed on other project'} | ${true} | ${['foo/bar']} | ${true} + ${'enabled, alert dismissed on this project'} | ${true} | ${[projectFullPath]} | ${false} + ${'not enabled'} | ${false} | ${null} | ${false} `('given Auto DevOps is $context', ({ autoDevopsEnabled, localStorageValue, shouldRender }) => { beforeEach(() => { if (localStorageValue !== null) { @@ -302,11 +302,11 @@ describe('App component', () => { describe('dismissing', () => { describe.each` - dismissedProjects | expectedWrittenValue - ${null} | ${[projectPath]} - ${[]} | ${[projectPath]} - ${['foo/bar']} | ${['foo/bar', projectPath]} - ${[projectPath]} | ${[projectPath]} + dismissedProjects | expectedWrittenValue + ${null} | ${[projectFullPath]} + ${[]} | ${[projectFullPath]} + ${['foo/bar']} | ${['foo/bar', projectFullPath]} + ${[projectFullPath]} | ${[projectFullPath]} `( 'given dismissed projects $dismissedProjects', ({ dismissedProjects, expectedWrittenValue }) => { diff --git a/spec/frontend/security_configuration/components/training_provider_list_spec.js b/spec/frontend/security_configuration/components/training_provider_list_spec.js index 578248e696f..f225c135d49 100644 --- a/spec/frontend/security_configuration/components/training_provider_list_spec.js +++ b/spec/frontend/security_configuration/components/training_provider_list_spec.js @@ -4,11 +4,12 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue'; +import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.graphql'; import configureSecurityTrainingProvidersMutation from '~/security_configuration/graphql/configure_security_training_providers.mutation.graphql'; import waitForPromises from 'helpers/wait_for_promises'; import { securityTrainingProviders, - createMockResolvers, + securityTrainingProvidersResponse, testProjectPath, textProviderIds, } from '../mock_data'; @@ -19,14 +20,19 @@ describe('TrainingProviderList component', () => { let wrapper; let apolloProvider; - const createApolloProvider = ({ resolvers } = {}) => { - apolloProvider = createMockApollo([], createMockResolvers({ resolvers })); + const createApolloProvider = ({ resolvers, queryHandler } = {}) => { + const defaultQueryHandler = jest.fn().mockResolvedValue(securityTrainingProvidersResponse); + + apolloProvider = createMockApollo( + [[securityTrainingProvidersQuery, queryHandler || defaultQueryHandler]], + resolvers, + ); }; const createComponent = () => { wrapper = shallowMount(TrainingProviderList, { provide: { - projectPath: testProjectPath, + projectFullPath: testProjectPath, }, apolloProvider, }); @@ -49,20 +55,29 @@ describe('TrainingProviderList component', () => { apolloProvider = null; }); - describe('with a successful response', () => { + describe('when loading', () => { beforeEach(() => { - createApolloProvider(); + const pendingHandler = () => new Promise(() => {}); + + createApolloProvider({ + queryHandler: pendingHandler, + }); createComponent(); }); - describe('when loading', () => { - it('shows the loader', () => { - expect(findLoader().exists()).toBe(true); - }); + it('shows the loader', () => { + expect(findLoader().exists()).toBe(true); + }); - it('does not show the cards', () => { - expect(findCards().exists()).toBe(false); - }); + it('does not show the cards', () => { + expect(findCards().exists()).toBe(false); + }); + }); + + describe('with a successful response', () => { + beforeEach(() => { + createApolloProvider(); + createComponent(); }); describe('basic structure', () => { @@ -142,11 +157,7 @@ describe('TrainingProviderList component', () => { describe('when fetching training providers', () => { beforeEach(async () => { createApolloProvider({ - resolvers: { - Query: { - securityTrainingProviders: jest.fn().mockReturnValue(new Error()), - }, - }, + queryHandler: jest.fn().mockReturnValue(new Error()), }); createComponent(); diff --git a/spec/frontend/security_configuration/mock_data.js b/spec/frontend/security_configuration/mock_data.js index 37ecce3886d..07c292020a3 100644 --- a/spec/frontend/security_configuration/mock_data.js +++ b/spec/frontend/security_configuration/mock_data.js @@ -21,19 +21,9 @@ export const securityTrainingProviders = [ export const securityTrainingProvidersResponse = { data: { - securityTrainingProviders, - }, -}; - -const defaultMockResolvers = { - Query: { - securityTrainingProviders() { - return securityTrainingProviders; + project: { + id: 1, + securityTrainingProviders, }, }, }; - -export const createMockResolvers = ({ resolvers: customMockResolvers = {} } = {}) => ({ - ...defaultMockResolvers, - ...customMockResolvers, -}); diff --git a/spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js b/spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js index 15eef79d425..a9fe29a484a 100644 --- a/spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js +++ b/spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js @@ -95,10 +95,11 @@ describe('Accessibility extension', () => { await waitForPromises(); - findToggleCollapsedButton().vm.$emit('click'); + findToggleCollapsedButton().trigger('click'); await waitForPromises(); }); + it('displays all report list items', async () => { expect(findAllExtensionListItems()).toHaveLength(10); }); diff --git a/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js b/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js index facbd51168c..39909e26ef0 100644 --- a/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js +++ b/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js @@ -16,7 +16,7 @@ jest.mock('~/lib/utils/url_utility'); Vue.use(VueApollo); -const projectPath = 'namespace/project'; +const projectFullPath = 'namespace/project'; describe('ManageViaMr component', () => { let wrapper; @@ -40,7 +40,7 @@ describe('ManageViaMr component', () => { wrapper = extendedWrapper( mount(ManageViaMr, { provide: { - projectPath, + projectFullPath, }, propsData: { feature: { @@ -65,7 +65,7 @@ describe('ManageViaMr component', () => { // the ones available in the current test context. const supportedReportTypes = Object.entries(featureToMutationMap).map( ([featureType, { getMutationPayload, mutationId }]) => { - const { mutation, variables: mutationVariables } = getMutationPayload(projectPath); + const { mutation, variables: mutationVariables } = getMutationPayload(projectFullPath); return [humanize(featureType), featureType, mutation, mutationId, mutationVariables]; }, ); diff --git a/spec/lib/gitlab/database/migrations/lock_retry_mixin_spec.rb b/spec/lib/gitlab/database/migrations/lock_retry_mixin_spec.rb index 076fb9e8215..50ad77caaf1 100644 --- a/spec/lib/gitlab/database/migrations/lock_retry_mixin_spec.rb +++ b/spec/lib/gitlab/database/migrations/lock_retry_mixin_spec.rb @@ -3,7 +3,8 @@ require 'spec_helper' RSpec.describe Gitlab::Database::Migrations::LockRetryMixin do describe Gitlab::Database::Migrations::LockRetryMixin::ActiveRecordMigrationProxyLockRetries do - let(:migration) { double } + let(:connection) { ActiveRecord::Base.connection } + let(:migration) { double(connection: connection) } let(:return_value) { double } let(:class_def) do Class.new do @@ -40,6 +41,18 @@ RSpec.describe Gitlab::Database::Migrations::LockRetryMixin do expect(result).to eq(return_value) end end + + describe '#migration_connection' do + subject { class_def.new(migration).migration_connection } + + it 'retrieves actual migration connection from #migration' do + expect(migration).to receive(:connection).and_return(return_value) + + result = subject + + expect(result).to eq(return_value) + end + end end describe Gitlab::Database::Migrations::LockRetryMixin::ActiveRecordMigratorLockRetries do @@ -96,7 +109,8 @@ RSpec.describe Gitlab::Database::Migrations::LockRetryMixin do context 'with transactions enabled and lock retries enabled' do let(:receiver) { double('receiver', use_transaction?: true)} - let(:migration) { double('migration', enable_lock_retries?: true) } + let(:migration) { double('migration', migration_connection: connection, enable_lock_retries?: true) } + let(:connection) { ActiveRecord::Base.connection } it 'calls super method' do p = proc { } diff --git a/spec/lib/gitlab/database/no_cross_db_foreign_keys_spec.rb b/spec/lib/gitlab/database/no_cross_db_foreign_keys_spec.rb index f815baa7d1d..ab42545e951 100644 --- a/spec/lib/gitlab/database/no_cross_db_foreign_keys_spec.rb +++ b/spec/lib/gitlab/database/no_cross_db_foreign_keys_spec.rb @@ -12,13 +12,11 @@ RSpec.describe 'cross-database foreign keys' do let(:allowed_cross_database_foreign_keys) do %w( ci_build_report_results.project_id - ci_builds.project_id ci_daily_build_group_report_results.group_id ci_daily_build_group_report_results.project_id ci_freeze_periods.project_id ci_job_artifacts.project_id ci_job_token_project_scope_links.added_by_id - ci_job_token_project_scope_links.target_project_id ci_pending_builds.namespace_id ci_pending_builds.project_id ci_pipeline_schedules.owner_id diff --git a/spec/lib/gitlab/database/with_lock_retries_outside_transaction_spec.rb b/spec/lib/gitlab/database/with_lock_retries_outside_transaction_spec.rb index 0282a7af0df..6c32fb3ca17 100644 --- a/spec/lib/gitlab/database/with_lock_retries_outside_transaction_spec.rb +++ b/spec/lib/gitlab/database/with_lock_retries_outside_transaction_spec.rb @@ -5,7 +5,8 @@ require 'spec_helper' RSpec.describe Gitlab::Database::WithLockRetriesOutsideTransaction do let(:env) { {} } let(:logger) { Gitlab::Database::WithLockRetries::NULL_LOGGER } - let(:subject) { described_class.new(env: env, logger: logger, timing_configuration: timing_configuration) } + let(:subject) { described_class.new(connection: connection, env: env, logger: logger, timing_configuration: timing_configuration) } + let(:connection) { ActiveRecord::Base.retrieve_connection } let(:timing_configuration) do [ @@ -67,7 +68,7 @@ RSpec.describe Gitlab::Database::WithLockRetriesOutsideTransaction do WHERE t.relkind = 'r' AND l.mode = 'ExclusiveLock' AND t.relname = '#{Project.table_name}' """ - expect(ActiveRecord::Base.connection.execute(check_exclusive_lock_query).to_a).to be_present + expect(connection.execute(check_exclusive_lock_query).to_a).to be_present end end @@ -96,8 +97,8 @@ RSpec.describe Gitlab::Database::WithLockRetriesOutsideTransaction do lock_fiber.resume end - ActiveRecord::Base.transaction do - ActiveRecord::Base.connection.execute("LOCK TABLE #{Project.table_name} in exclusive mode") + connection.transaction do + connection.execute("LOCK TABLE #{Project.table_name} in exclusive mode") lock_acquired = true end end @@ -115,7 +116,7 @@ RSpec.describe Gitlab::Database::WithLockRetriesOutsideTransaction do context 'setting the idle transaction timeout' do context 'when there is no outer transaction: disable_ddl_transaction! is set in the migration' do it 'does not disable the idle transaction timeout' do - allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false) + allow(connection).to receive(:transaction_open?).and_return(false) allow(subject).to receive(:run_block_with_lock_timeout).once.and_raise(ActiveRecord::LockWaitTimeout) allow(subject).to receive(:run_block_with_lock_timeout).once @@ -127,7 +128,7 @@ RSpec.describe Gitlab::Database::WithLockRetriesOutsideTransaction do context 'when there is outer transaction: disable_ddl_transaction! is not set in the migration' do it 'disables the idle transaction timeout so the code can sleep and retry' do - allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(true) + allow(connection).to receive(:transaction_open?).and_return(true) n = 0 allow(subject).to receive(:run_block_with_lock_timeout).twice do @@ -184,8 +185,8 @@ RSpec.describe Gitlab::Database::WithLockRetriesOutsideTransaction do subject.run(raise_on_exhaustion: true) do lock_attempts += 1 - ActiveRecord::Base.transaction do - ActiveRecord::Base.connection.execute("LOCK TABLE #{Project.table_name} in exclusive mode") + connection.transaction do + connection.execute("LOCK TABLE #{Project.table_name} in exclusive mode") lock_acquired = true end end @@ -199,11 +200,11 @@ RSpec.describe Gitlab::Database::WithLockRetriesOutsideTransaction do context 'when statement timeout is reached' do it 'raises StatementInvalid error' do lock_acquired = false - ActiveRecord::Base.connection.execute("SET statement_timeout='100ms'") + connection.execute("SET statement_timeout='100ms'") expect do subject.run do - ActiveRecord::Base.connection.execute("SELECT 1 FROM pg_sleep(0.11)") # 110ms + connection.execute("SELECT 1 FROM pg_sleep(0.11)") # 110ms lock_acquired = true end end.to raise_error(ActiveRecord::StatementInvalid) @@ -216,11 +217,11 @@ RSpec.describe Gitlab::Database::WithLockRetriesOutsideTransaction do context 'restore local database variables' do it do - expect { subject.run {} }.not_to change { ActiveRecord::Base.connection.execute("SHOW lock_timeout").to_a } + expect { subject.run {} }.not_to change { connection.execute("SHOW lock_timeout").to_a } end it do - expect { subject.run {} }.not_to change { ActiveRecord::Base.connection.execute("SHOW idle_in_transaction_session_timeout").to_a } + expect { subject.run {} }.not_to change { connection.execute("SHOW idle_in_transaction_session_timeout").to_a } end end @@ -228,8 +229,8 @@ RSpec.describe Gitlab::Database::WithLockRetriesOutsideTransaction do let(:timing_configuration) { [[0.015.seconds, 0.025.seconds], [0.015.seconds, 0.025.seconds]] } # 15ms, 25ms it 'executes `SET lock_timeout` using the configured timeout value in milliseconds' do - expect(ActiveRecord::Base.connection).to receive(:execute).with('RESET idle_in_transaction_session_timeout; RESET lock_timeout').and_call_original - expect(ActiveRecord::Base.connection).to receive(:execute).with("SET lock_timeout TO '15ms'").and_call_original + expect(connection).to receive(:execute).with('RESET idle_in_transaction_session_timeout; RESET lock_timeout').and_call_original + expect(connection).to receive(:execute).with("SET lock_timeout TO '15ms'").and_call_original subject.run { } end diff --git a/spec/lib/gitlab/database/with_lock_retries_spec.rb b/spec/lib/gitlab/database/with_lock_retries_spec.rb index c2c818aa106..6b35ccafabc 100644 --- a/spec/lib/gitlab/database/with_lock_retries_spec.rb +++ b/spec/lib/gitlab/database/with_lock_retries_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Gitlab::Database::WithLockRetries do let(:env) { {} } let(:logger) { Gitlab::Database::WithLockRetries::NULL_LOGGER } - let(:subject) { described_class.new(env: env, logger: logger, allow_savepoints: allow_savepoints, timing_configuration: timing_configuration) } + let(:subject) { described_class.new(connection: connection, env: env, logger: logger, allow_savepoints: allow_savepoints, timing_configuration: timing_configuration) } let(:allow_savepoints) { true } let(:connection) { ActiveRecord::Base.retrieve_connection } diff --git a/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb b/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb index d69d775fffb..352af18c822 100644 --- a/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb +++ b/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb @@ -183,24 +183,8 @@ RSpec.describe Gitlab::ImportExport::Json::StreamingSerializer do end describe '.batch_size' do - context 'when export_reduce_relation_batch_size feature flag is enabled' do - before do - stub_feature_flags(export_reduce_relation_batch_size: true) - end - - it 'returns 20' do - expect(described_class.batch_size(exportable)).to eq(described_class::SMALLER_BATCH_SIZE) - end - end - - context 'when export_reduce_relation_batch_size feature flag is disabled' do - before do - stub_feature_flags(export_reduce_relation_batch_size: false) - end - - it 'returns default batch size' do - expect(described_class.batch_size(exportable)).to eq(described_class::BATCH_SIZE) - end + it 'returns default batch size' do + expect(described_class.batch_size(exportable)).to eq(described_class::BATCH_SIZE) end end end diff --git a/spec/lib/gitlab/import_export/legacy_relation_tree_saver_spec.rb b/spec/lib/gitlab/import_export/legacy_relation_tree_saver_spec.rb index 3b7ed7cb32b..0d372def8b0 100644 --- a/spec/lib/gitlab/import_export/legacy_relation_tree_saver_spec.rb +++ b/spec/lib/gitlab/import_export/legacy_relation_tree_saver_spec.rb @@ -8,35 +8,17 @@ RSpec.describe Gitlab::ImportExport::LegacyRelationTreeSaver do let(:tree) { {} } describe '#serialize' do - shared_examples 'FastHashSerializer with batch size' do |batch_size| - let(:serializer) { instance_double(Gitlab::ImportExport::FastHashSerializer) } + let(:serializer) { instance_double(Gitlab::ImportExport::FastHashSerializer) } - it 'uses FastHashSerializer' do - expect(Gitlab::ImportExport::FastHashSerializer) - .to receive(:new) - .with(exportable, tree, batch_size: batch_size) - .and_return(serializer) + it 'uses FastHashSerializer' do + expect(Gitlab::ImportExport::FastHashSerializer) + .to receive(:new) + .with(exportable, tree, batch_size: Gitlab::ImportExport::Json::StreamingSerializer::BATCH_SIZE) + .and_return(serializer) - expect(serializer).to receive(:execute) + expect(serializer).to receive(:execute) - relation_tree_saver.serialize(exportable, tree) - end - end - - context 'when export_reduce_relation_batch_size feature flag is enabled' do - before do - stub_feature_flags(export_reduce_relation_batch_size: true) - end - - include_examples 'FastHashSerializer with batch size', Gitlab::ImportExport::Json::StreamingSerializer::SMALLER_BATCH_SIZE - end - - context 'when export_reduce_relation_batch_size feature flag is disabled' do - before do - stub_feature_flags(export_reduce_relation_batch_size: false) - end - - include_examples 'FastHashSerializer with batch size', Gitlab::ImportExport::Json::StreamingSerializer::BATCH_SIZE + relation_tree_saver.serialize(exportable, tree) end end end diff --git a/spec/models/ci/job_token/project_scope_link_spec.rb b/spec/models/ci/job_token/project_scope_link_spec.rb index 8494446476f..c000a3e29f7 100644 --- a/spec/models/ci/job_token/project_scope_link_spec.rb +++ b/spec/models/ci/job_token/project_scope_link_spec.rb @@ -95,4 +95,11 @@ RSpec.describe Ci::JobToken::ProjectScopeLink do let!(:model) { create(:ci_job_token_project_scope_link, source_project: parent) } end end + + context 'loose foreign key on ci_job_token_project_scope_links.target_project_id' do + it_behaves_like 'cleanup by a loose foreign key' do + let!(:parent) { create(:project) } + let!(:model) { create(:ci_job_token_project_scope_link, target_project: parent) } + end + end end diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb index d5e74d36b58..86ee159b97e 100644 --- a/spec/models/commit_status_spec.rb +++ b/spec/models/commit_status_spec.rb @@ -987,4 +987,11 @@ RSpec.describe CommitStatus do commit_status.expire_etag_cache! end end + + context 'loose foreign key on ci_builds.project_id' do + it_behaves_like 'cleanup by a loose foreign key' do + let!(:parent) { create(:project) } + let!(:model) { create(:ci_build, project: parent) } + end + end end diff --git a/spec/support/shared_examples/workers/project_export_shared_examples.rb b/spec/support/shared_examples/workers/project_export_shared_examples.rb index a9bcc3f4f7c..175ef9bd012 100644 --- a/spec/support/shared_examples/workers/project_export_shared_examples.rb +++ b/spec/support/shared_examples/workers/project_export_shared_examples.rb @@ -53,6 +53,10 @@ RSpec.shared_examples 'export worker' do it 'does not raise an exception when strategy is invalid' do expect(::Projects::ImportExport::ExportService).not_to receive(:new) + expect_next_instance_of(ProjectExportJob) do |job| + expect(job).to receive(:finish) + end + expect { subject.perform(user.id, project.id, { 'klass' => 'Whatever' }) }.not_to raise_error end @@ -63,6 +67,18 @@ RSpec.shared_examples 'export worker' do it 'does not raise error when user cannot be found' do expect { subject.perform(non_existing_record_id, project.id, {}) }.not_to raise_error end + + it 'fails the export job status' do + expect_next_instance_of(::Projects::ImportExport::ExportService) do |service| + expect(service).to receive(:execute).and_raise(Gitlab::ImportExport::Error) + end + + expect_next_instance_of(ProjectExportJob) do |job| + expect(job).to receive(:fail_op) + end + + expect { subject.perform(user.id, project.id, {}) }.to raise_error(Gitlab::ImportExport::Error) + end end end diff --git a/spec/workers/auto_devops/disable_worker_spec.rb b/spec/workers/auto_devops/disable_worker_spec.rb index 239f4b09f5c..e1de97e0ce5 100644 --- a/spec/workers/auto_devops/disable_worker_spec.rb +++ b/spec/workers/auto_devops/disable_worker_spec.rb @@ -26,7 +26,7 @@ RSpec.describe AutoDevops::DisableWorker, '#perform' do let(:namespace) { create(:namespace, owner: owner) } let(:project) { create(:project, :repository, :auto_devops, namespace: namespace) } - it 'sends an email to pipeline user and project owner' do + it 'sends an email to pipeline user and project owner(s)' do expect(NotificationService).to receive_message_chain(:new, :autodevops_disabled).with(pipeline, [user.email, owner.email]) subject.perform(pipeline.id) |