diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-04-19 15:09:04 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-04-19 15:09:04 +0300 |
commit | c6af94ea4ea649171ff930b6bf94c73a5d03edb9 (patch) | |
tree | ceef77238b3a275a3a32b4e9f982b6d2f27e0c6b | |
parent | 3257ae3af07a4ad026be3c868e74ff82866fc400 (diff) |
Add latest changes from gitlab-org/gitlab@master
86 files changed, 1343 insertions, 657 deletions
diff --git a/.rubocop_manual_todo.yml b/.rubocop_manual_todo.yml index 185bbb36e60..c4e37e6c42f 100644 --- a/.rubocop_manual_todo.yml +++ b/.rubocop_manual_todo.yml @@ -2911,33 +2911,6 @@ Gitlab/NamespacedClass: - 'spec/tasks/gitlab/task_helpers_spec.rb' - 'spec/uploaders/object_storage_spec.rb' -# WIP: https://gitlab.com/gitlab-org/gitlab/-/issues/322739 -Style/HashTransformation: - Exclude: - - 'ee/app/models/ee/ci/build.rb' - - 'ee/app/models/productivity_analytics.rb' - - 'ee/app/models/sca/license_compliance.rb' - - 'ee/app/services/security/store_report_service.rb' - - 'ee/lib/ee/gitlab/auth/ldap/sync/group.rb' - - 'ee/lib/ee/gitlab/usage_data.rb' - - 'ee/lib/gitlab/custom_file_templates.rb' - - 'ee/spec/elastic_integration/global_search_spec.rb' - - 'ee/spec/lib/ee/gitlab/application_context_spec.rb' - - 'spec/lib/atlassian/jira_connect/serializers/pull_request_entity_spec.rb' - - 'spec/lib/gitlab/ci/status/composite_spec.rb' - - 'spec/lib/gitlab/conflict/file_spec.rb' - - 'spec/lib/gitlab/import_export/project/tree_restorer_spec.rb' - - 'spec/models/concerns/featurable_spec.rb' - - 'spec/models/event_spec.rb' - - 'spec/models/packages/dependency_spec.rb' - - 'spec/requests/api/graphql/project/alert_management/alert/assignees_spec.rb' - - 'spec/requests/api/graphql/project/alert_management/alert/notes_spec.rb' - - 'spec/requests/api/graphql/project/alert_management/alert/todos_spec.rb' - - 'spec/requests/api/projects_spec.rb' - - 'spec/support/helpers/graphql_helpers.rb' - - 'spec/support/import_export/project_tree_expectations.rb' - - 'spec/support/shared_contexts/services/projects/container_repository/delete_tags_service_shared_context.rb' - Style/ClassEqualityComparison: Exclude: - spec/lib/peek/views/active_record_spec.rb @@ -342,7 +342,6 @@ group :metrics do end group :development do - gem 'brakeman', '~> 4.10.0', require: false gem 'lefthook', '~> 0.7.0', require: false gem 'letter_opener_web', '~> 1.4.0' @@ -383,7 +382,7 @@ group :development, :test do gem 'benchmark-ips', '~> 2.3.0', require: false - gem 'knapsack', '~> 1.17' + gem 'knapsack', '~> 1.21.1' gem 'crystalball', '~> 0.7.0', require: false gem 'simple_po_parser', '~> 1.1.2', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 4ad1c2420a0..19fad573b63 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -151,7 +151,6 @@ GEM bootstrap_form (4.2.0) actionpack (>= 5.0) activemodel (>= 5.0) - brakeman (4.10.1) browser (4.2.0) builder (3.2.4) bullet (6.1.3) @@ -672,7 +671,7 @@ GEM kaminari-core (= 1.2.1) kaminari-core (1.2.1) kgio (2.11.3) - knapsack (1.17.0) + knapsack (1.21.1) rake kramdown (2.3.1) rexml @@ -1369,7 +1368,6 @@ DEPENDENCIES better_errors (~> 2.9.0) bootsnap (~> 1.4.6) bootstrap_form (~> 4.2.0) - brakeman (~> 4.10.0) browser (~> 4.2) bullet (~> 6.1.3) bundler-audit (~> 0.7.0.1) @@ -1476,7 +1474,7 @@ DEPENDENCIES json_schemer (~> 0.2.12) jwt (~> 2.1.0) kaminari (~> 1.0) - knapsack (~> 1.17) + knapsack (~> 1.21.1) kramdown (~> 2.3.1) kubeclient (~> 4.9.1) lefthook (~> 0.7.0) @@ -4,6 +4,8 @@ # Add your own tasks in files placed in lib/tasks ending in .rake, # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. +Rake::TaskManager.record_task_metadata = true + require File.expand_path('config/application', __dir__) relative_url_conf = File.expand_path('config/initializers/relative_url', __dir__) diff --git a/app/assets/javascripts/batch_comments/mixins/resolved_status.js b/app/assets/javascripts/batch_comments/mixins/resolved_status.js index 85bdf6b7a36..bec360e3b2e 100644 --- a/app/assets/javascripts/batch_comments/mixins/resolved_status.js +++ b/app/assets/javascripts/batch_comments/mixins/resolved_status.js @@ -50,12 +50,18 @@ export default { return this.resolveDiscussion ? 'is-resolving-discussion' : 'is-unresolving-discussion'; }, resolveButtonTitle() { + const escapeParameters = false; + if (this.isDraft || this.discussionId) return this.resolvedStatusMessage; let title = __('Resolve thread'); if (this.resolvedBy) { - title = sprintf(__('Resolved by %{name}'), { name: this.resolvedBy.name }); + title = sprintf( + __('Resolved by %{name}'), + { name: this.resolvedBy.name }, + escapeParameters, + ); } return title; diff --git a/app/assets/javascripts/invite_member/init_invite_member_modal.js b/app/assets/javascripts/invite_member/init_invite_member_modal.js index 108f636ee3e..a50d31c9e7a 100644 --- a/app/assets/javascripts/invite_member/init_invite_member_modal.js +++ b/app/assets/javascripts/invite_member/init_invite_member_modal.js @@ -5,10 +5,13 @@ import InviteMemberModal from './components/invite_member_modal.vue'; Vue.use(GlToast); +const isAssigneesWidgetShown = + (isInIssuePage() || isInDesignPage()) && gon.features.issueAssigneesWidget; + export default function initInviteMembersModal() { const el = document.querySelector('.js-invite-member-modal'); - if (!el || isInDesignPage() || isInIssuePage()) { + if (!el || isAssigneesWidgetShown) { return false; } diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index a25862a587b..a70bac94b71 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -390,7 +390,7 @@ export default { <gl-button :disabled="isDisabled" category="primary" - variant="success" + variant="confirm" class="gl-mr-3" data-qa-selector="start_review_button" @click="handleAddToReview" diff --git a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue index d61136cda8d..455990f2791 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue +++ b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue @@ -1,29 +1,19 @@ <script> -import { GlAlert, GlIcon } from '@gitlab/ui'; +import { GlIcon } from '@gitlab/ui'; import { uniqueId } from 'lodash'; -import { __, s__ } from '~/locale'; -import { DEFAULT, INVALID_CI_CONFIG } from '~/pipelines/constants'; +import { s__ } from '~/locale'; import EditorLite from '~/vue_shared/components/editor_lite.vue'; export default { i18n: { viewOnlyMessage: s__('Pipelines|Merged YAML is view only'), }, - errorTexts: { - [INVALID_CI_CONFIG]: __('Your CI configuration file is invalid.'), - [DEFAULT]: __('An unknown error occurred.'), - }, components: { EditorLite, - GlAlert, GlIcon, }, inject: ['ciConfigPath'], props: { - isValid: { - type: Boolean, - required: true, - }, ciConfigData: { type: Object, required: true, @@ -35,66 +25,30 @@ export default { }; }, computed: { - failure() { - switch (this.failureType) { - case INVALID_CI_CONFIG: - return this.$options.errorTexts[INVALID_CI_CONFIG]; - default: - return this.$options.errorTexts[DEFAULT]; - } - }, fileGlobalId() { return `${this.ciConfigPath}-${uniqueId()}`; }, - hasError() { - return this.failureType; - }, mergedYaml() { return this.ciConfigData.mergedYaml; }, }, - watch: { - ciConfigData: { - immediate: true, - handler() { - if (!this.isValid) { - this.reportFailure(INVALID_CI_CONFIG); - } else if (this.hasError) { - this.resetFailure(); - } - }, - }, - }, - methods: { - reportFailure(errorType) { - this.failureType = errorType; - }, - resetFailure() { - this.failureType = null; - }, - }, }; </script> <template> <div> - <gl-alert v-if="hasError" variant="danger" :dismissible="false"> - {{ failure }} - </gl-alert> - <div v-else> - <div class="gl-display-flex gl-align-items-center"> - <gl-icon :size="16" name="lock" class="gl-text-gray-500 gl-mr-3" /> - {{ $options.i18n.viewOnlyMessage }} - </div> - <div class="gl-mt-3 gl-border-solid gl-border-gray-100 gl-border-1"> - <editor-lite - ref="editor" - :value="mergedYaml" - :file-name="ciConfigPath" - :file-global-id="fileGlobalId" - :editor-options="{ readOnly: true }" - v-on="$listeners" - /> - </div> + <div class="gl-display-flex gl-align-items-center"> + <gl-icon :size="16" name="lock" class="gl-text-gray-500 gl-mr-3" /> + {{ $options.i18n.viewOnlyMessage }} + </div> + <div class="gl-mt-3 gl-border-solid gl-border-gray-100 gl-border-1"> + <editor-lite + ref="editor" + :value="mergedYaml" + :file-name="ciConfigPath" + :file-global-id="fileGlobalId" + :editor-options="{ readOnly: true }" + v-on="$listeners" + /> </div> </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue index 760d395ff2c..5acb3355b23 100644 --- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue @@ -1,11 +1,13 @@ <script> -import { GlAlert, GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui'; import { s__ } from '~/locale'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { CREATE_TAB, + EDITOR_APP_STATUS_EMPTY, EDITOR_APP_STATUS_ERROR, + EDITOR_APP_STATUS_INVALID, EDITOR_APP_STATUS_LOADING, EDITOR_APP_STATUS_VALID, LINT_TAB, @@ -24,6 +26,17 @@ export default { tabGraph: s__('Pipelines|Visualize'), tabLint: s__('Pipelines|Lint'), tabMergedYaml: s__('Pipelines|View merged YAML'), + empty: { + visualization: s__( + 'PipelineEditor|The pipeline visualization is displayed when the CI/CD configuration file has valid syntax.', + ), + lint: s__( + 'PipelineEditor|The CI/CD configuration is continuously validated. Errors and warnings are displayed when the CI/CD configuration file is not empty.', + ), + merge: s__( + 'PipelineEditor|The merged YAML view is displayed when the CI/CD configuration file has valid syntax.', + ), + }, }, errorTexts: { loadMergedYaml: s__('Pipelines|Could not load merged YAML content'), @@ -40,7 +53,6 @@ export default { EditorTab, GlAlert, GlLoadingIcon, - GlTab, GlTabs, PipelineGraph, TextEditor, @@ -66,6 +78,12 @@ export default { // Not an invalid config and with `mergedYaml` data missing return this.appStatus === EDITOR_APP_STATUS_ERROR; }, + isEmpty() { + return this.appStatus === EDITOR_APP_STATUS_EMPTY; + }, + isInvalid() { + return this.appStatus === EDITOR_APP_STATUS_INVALID; + }, isValid() { return this.appStatus === EDITOR_APP_STATUS_VALID; }, @@ -91,9 +109,12 @@ export default { > <text-editor :value="ciFileContent" v-on="$listeners" /> </editor-tab> - <gl-tab + <editor-tab v-if="glFeatures.ciConfigVisualizationTab" class="gl-mb-3" + :empty-message="$options.i18n.empty.visualization" + :is-empty="isEmpty" + :is-invalid="isInvalid" :title="$options.i18n.tabGraph" lazy data-testid="visualization-tab" @@ -101,9 +122,11 @@ export default { > <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" /> <pipeline-graph v-else :pipeline-data="ciConfigData" /> - </gl-tab> + </editor-tab> <editor-tab class="gl-mb-3" + :empty-message="$options.i18n.empty.lint" + :is-empty="isEmpty" :title="$options.i18n.tabLint" data-testid="lint-tab" @click="setCurrentTab($options.tabConstants.LINT_TAB)" @@ -111,9 +134,13 @@ export default { <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" /> <ci-lint v-else :is-valid="isValid" :ci-config="ciConfigData" /> </editor-tab> - <gl-tab + <editor-tab v-if="glFeatures.ciConfigMergedTab" class="gl-mb-3" + :empty-message="$options.i18n.empty.merge" + :keep-component-mounted="false" + :is-empty="isEmpty" + :is-invalid="isInvalid" :title="$options.i18n.tabMergedYaml" lazy data-testid="merged-tab" @@ -123,12 +150,7 @@ export default { <gl-alert v-else-if="hasAppError" variant="danger" :dismissible="false"> {{ $options.errorTexts.loadMergedYaml }} </gl-alert> - <ci-config-merged-preview - v-else - :is-valid="isValid" - :ci-config-data="ciConfigData" - v-on="$listeners" - /> - </gl-tab> + <ci-config-merged-preview v-else :ci-config-data="ciConfigData" v-on="$listeners" /> + </editor-tab> </gl-tabs> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue b/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue index ce8ee7493fe..7c032441a04 100644 --- a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue +++ b/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue @@ -1,6 +1,6 @@ <script> -import { GlTab } from '@gitlab/ui'; - +import { GlAlert, GlTab } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; /** * Wrapper of <gl-tab> to optionally lazily render this tab's content * when its shown **without dismounting after its hidden**. @@ -10,10 +10,10 @@ import { GlTab } from '@gitlab/ui'; * API is the same as <gl-tab>, for example: * * <gl-tabs> - * <editor-tab title="Tab 1" :lazy="true"> + * <editor-tab title="Tab 1" lazy> * lazily mounted content (gets mounted if this is first tab) * </editor-tab> - * <editor-tab title="Tab 2" :lazy="true"> + * <editor-tab title="Tab 2" lazy> * lazily mounted content * </editor-tab> * <editor-tab title="Tab 3"> @@ -25,10 +25,26 @@ import { GlTab } from '@gitlab/ui'; * so it's contents are not dismounted. * * lazy is "false" by default, as in <gl-tab>. + * + * It is also possible to pass the `isEmpty` and or `isInvalid` to let + * the tab component handle that state on its own. For example: + * + * * <gl-tabs> + * <editor-tab-with-status title="Tab 1" :is-empty="isEmpty" :is-invalid="isInvalid"> + * ... + * </editor-tab-with-status> + * Will be the same as normal, except it will only render the slot component + * if the status is not empty and not invalid. In any of these 2 cases, it will render + * a generic component and avoid mounting whatever it received in the slot. + * </gl-tabs> */ export default { + i18n: { + invalid: __('Your CI/CD configuration syntax is invalid. View Lint tab for more details.'), + }, components: { + GlAlert, GlTab, // Use a small renderless component to know when the tab content mounts because: // - gl-tab always gets mounted, even if lazy is `true`. See: @@ -40,29 +56,63 @@ export default { }, inheritAttrs: false, props: { + emptyMessage: { + type: String, + required: false, + default: s__( + 'PipelineEditor|This tab will be usable when the CI/CD configuration file is populated with valid syntax.', + ), + }, + isEmpty: { + type: Boolean, + required: false, + default: null, + }, + isInvalid: { + type: Boolean, + required: false, + default: null, + }, lazy: { type: Boolean, required: false, default: false, }, + keepComponentMounted: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { isLazy: this.lazy, }; }, + computed: { + slots() { + return Object.keys(this.$slots); + }, + }, methods: { onContentMounted() { // When a child is first mounted make the entire tab - // permanently mounted by setting 'lazy' to false. - this.isLazy = false; + // permanently mounted by setting 'lazy' to false unless + // explicitly opted out. + if (this.keepComponentMounted) { + this.isLazy = false; + } }, }, }; </script> <template> <gl-tab :lazy="isLazy" v-bind="$attrs" v-on="$listeners"> - <slot v-for="slot in Object.keys($slots)" :name="slot"></slot> - <mount-spy @hook:mounted="onContentMounted" /> + <gl-alert v-if="isEmpty" variant="tip">{{ emptyMessage }}</gl-alert> + <gl-alert v-else-if="isInvalid" variant="danger">{{ $options.i18n.invalid }}</gl-alert> + <template v-else> + <slot v-for="slot in slots" :name="slot"></slot> + <mount-spy @hook:mounted="onContentMounted" /> + </template> </gl-tab> </template> diff --git a/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue b/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue new file mode 100644 index 00000000000..6982586ab12 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue @@ -0,0 +1,90 @@ +<script> +import { GlBanner, GlLink, GlSprintf } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; +import DismissPipelineNotification from '../../graphql/mutations/dismiss_pipeline_notification.graphql'; +import getUserCallouts from '../../graphql/queries/get_user_callouts.query.graphql'; + +const featureName = 'pipeline_needs_banner'; +const enumFeatureName = featureName.toUpperCase(); + +export default { + i18n: { + title: __('View job dependencies in the pipeline graph!'), + description: __( + 'You can now group jobs in the pipeline graph based on which jobs are configured to run first, if you use the %{codeStart}needs:%{codeEnd} keyword to establish job dependencies in your CI/CD pipelines. %{linkStart}Learn how to speed up your pipeline with needs.%{linkEnd}', + ), + buttonText: __('Provide feedback'), + }, + components: { + GlBanner, + GlLink, + GlSprintf, + }, + apollo: { + callouts: { + query: getUserCallouts, + update(data) { + return data?.currentUser?.callouts?.nodes.map((c) => c.featureName); + }, + error() { + this.hasError = true; + }, + }, + }, + inject: ['dagDocPath'], + data() { + return { + callouts: [], + dismissedAlert: false, + hasError: false, + }; + }, + computed: { + showBanner() { + return ( + !this.$apollo.queries.callouts?.loading && + !this.hasError && + !this.dismissedAlert && + !this.callouts.includes(enumFeatureName) + ); + }, + }, + methods: { + handleClose() { + this.dismissedAlert = true; + try { + this.$apollo.mutate({ + mutation: DismissPipelineNotification, + variables: { + featureName, + }, + }); + } catch { + createFlash(__('There was a problem dismissing this notification.')); + } + }, + }, +}; +</script> +<template> + <gl-banner + v-if="showBanner" + :title="$options.i18n.title" + :button-text="$options.i18n.buttonText" + button-link="https://gitlab.com/gitlab-org/gitlab/-/issues/327688" + variant="introduction" + @close="handleClose" + > + <p> + <gl-sprintf :message="$options.i18n.description"> + <template #link="{ content }"> + <gl-link :href="dagDocPath" target="_blank"> {{ content }}</gl-link> + </template> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + </gl-banner> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue index 1f8c4a9aa8b..3ba0d7d0120 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue @@ -1,8 +1,7 @@ <script> import { GlAlert } from '@gitlab/ui'; import { __ } from '~/locale'; -import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants'; -import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants'; +import { DRAW_FAILURE, DEFAULT } from '../../constants'; import LinksLayer from '../graph_shared/links_layer.vue'; import JobPill from './job_pill.vue'; import StagePill from './stage_pill.vue'; @@ -21,10 +20,6 @@ export default { errorTexts: { [DRAW_FAILURE]: __('Could not draw the lines for job relationships'), [DEFAULT]: __('An unknown error occurred.'), - [EMPTY_PIPELINE_DATA]: __( - 'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.', - ), - [INVALID_CI_CONFIG]: __('Your CI configuration file is invalid.'), }, props: { pipelineData: { @@ -55,18 +50,6 @@ export default { variant: 'danger', dismissible: true, }; - case EMPTY_PIPELINE_DATA: - return { - text: this.$options.errorTexts[EMPTY_PIPELINE_DATA], - variant: 'tip', - dismissible: false, - }; - case INVALID_CI_CONFIG: - return { - text: this.$options.errorTexts[INVALID_CI_CONFIG], - variant: 'danger', - dismissible: false, - }; default: return { text: this.$options.errorTexts[DEFAULT], @@ -81,18 +64,6 @@ export default { hasHighlightedJob() { return Boolean(this.highlightedJob); }, - hideGraph() { - // We won't even try to render the graph with these condition - // because it would cause additional errors down the line for the user - // which is confusing. - return this.isPipelineDataEmpty || this.isInvalidCiConfig; - }, - isInvalidCiConfig() { - return this.pipelineData?.status === CI_CONFIG_STATUS_INVALID; - }, - isPipelineDataEmpty() { - return !this.isInvalidCiConfig && this.pipelineStages.length === 0; - }, pipelineStages() { return this.pipelineData?.stages || []; }, @@ -101,15 +72,9 @@ export default { pipelineData: { immediate: true, handler() { - if (this.isPipelineDataEmpty) { - this.reportFailure(EMPTY_PIPELINE_DATA); - } else if (this.isInvalidCiConfig) { - this.reportFailure(INVALID_CI_CONFIG); - } else { - this.$nextTick(() => { - this.computeGraphDimensions(); - }); - } + this.$nextTick(() => { + this.computeGraphDimensions(); + }); }, }, }, @@ -172,12 +137,7 @@ export default { > {{ failure.text }} </gl-alert> - <div - v-if="!hideGraph" - :id="containerId" - :ref="$options.CONTAINER_REF" - data-testid="graph-container" - > + <div :id="containerId" :ref="$options.CONTAINER_REF" data-testid="graph-container"> <links-layer :pipeline-data="pipelineStages" :pipeline-id="$options.PIPELINE_ID" diff --git a/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql b/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql new file mode 100644 index 00000000000..e4fd55a28be --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql @@ -0,0 +1,5 @@ +mutation DismissPipelineNotification($featureName: String!) { + userCalloutCreate(input: { featureName: $featureName }) { + errors + } +} diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql new file mode 100644 index 00000000000..12b391e41ac --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql @@ -0,0 +1,13 @@ +query getUser { + currentUser { + id + __typename + callouts { + __typename + nodes { + __typename + featureName + } + } + } +} diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index cc53532b554..a2bc049c3c7 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -8,6 +8,7 @@ import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import createDagApp from './pipeline_details_dag'; import { createPipelinesDetailApp } from './pipeline_details_graph'; import { createPipelineHeaderApp } from './pipeline_details_header'; +import { createPipelineNotificationApp } from './pipeline_details_notification'; import { apolloProvider } from './pipeline_shared_client'; import createTestReportsStore from './stores/test_reports'; import { reportToSentry } from './utils'; @@ -18,6 +19,7 @@ const SELECTORS = { PIPELINE_DETAILS: '.js-pipeline-details-vue', PIPELINE_GRAPH: '#js-pipeline-graph-vue', PIPELINE_HEADER: '#js-pipeline-header-vue', + PIPELINE_NOTIFICATION: '#js-pipeline-notification', PIPELINE_TESTS: '#js-pipeline-tests-detail', }; @@ -93,6 +95,14 @@ export default async function initPipelineDetailsBundle() { Flash(__('An error occurred while loading a section of this page.')); } + if (gon.features.pipelineGraphLayersView) { + try { + createPipelineNotificationApp(SELECTORS.PIPELINE_NOTIFICATION, apolloProvider); + } catch { + Flash(__('An error occurred while loading a section of this page.')); + } + } + if (canShowNewPipelineDetails) { try { createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset); diff --git a/app/assets/javascripts/pipelines/pipeline_details_notification.js b/app/assets/javascripts/pipelines/pipeline_details_notification.js new file mode 100644 index 00000000000..be234e8972d --- /dev/null +++ b/app/assets/javascripts/pipelines/pipeline_details_notification.js @@ -0,0 +1,29 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import PipelineNotification from './components/notification/pipeline_notification.vue'; + +Vue.use(VueApollo); + +export const createPipelineNotificationApp = (elSelector, apolloProvider) => { + const el = document.querySelector(elSelector); + + if (!el) { + return; + } + + const { dagDocPath } = el?.dataset; + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + PipelineNotification, + }, + provide: { + dagDocPath, + }, + apolloProvider, + render(createElement) { + return createElement('pipeline-notification'); + }, + }); +}; diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 643a57bf6a8..1304e84814b 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -410,8 +410,11 @@ function mountCopyEmailComponent() { }); } +const isAssigneesWidgetShown = + (isInIssuePage() || isInDesignPage()) && gon.features.issueAssigneesWidget; + export function mountSidebar(mediator) { - if (isInIssuePage() || isInDesignPage()) { + if (isAssigneesWidgetShown) { mountAssigneesComponent(); } else { mountAssigneesComponentDeprecated(mediator); diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index 4a15e0eb458..fa5ab590232 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -26,7 +26,6 @@ @import './pages/projects'; @import './pages/prometheus'; @import './pages/registry'; -@import './pages/runners'; @import './pages/search'; @import './pages/service_desk'; @import './pages/settings'; diff --git a/app/assets/stylesheets/pages/runners.scss b/app/assets/stylesheets/pages/runners.scss deleted file mode 100644 index d01c9a9fd42..00000000000 --- a/app/assets/stylesheets/pages/runners.scss +++ /dev/null @@ -1,13 +0,0 @@ -.runner-state { - padding: 6px 12px; - margin-right: 10px; - color: $white; - - &.runner-state-shared { - background: $green-400; - } - - &.runner-state-specific { - background: $blue-400; - } -} diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 74d993fb198..cae5cc411bc 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -54,6 +54,7 @@ class Projects::IssuesController < Projects::ApplicationController push_to_gon_attributes(:features, real_time_feature_flag, real_time_enabled) push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml) + push_frontend_feature_flag(:issue_assignees_widget, @project, default_enabled: :yaml) record_experiment_user(:invite_members_version_b) diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 09a20c3509f..15f6bedfc2e 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -20,7 +20,7 @@ class ProjectFeature < ApplicationRecord container_registry ].freeze - EXPORTABLE_FEATURES = (FEATURES - [:security_and_compliance]).freeze + EXPORTABLE_FEATURES = (FEATURES - [:security_and_compliance, :pages]).freeze set_available_features(FEATURES) diff --git a/app/models/todo.rb b/app/models/todo.rb index 6d6ee07cfaa..c8138587d83 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -152,6 +152,20 @@ class Todo < ApplicationRecord def pluck_user_id pluck(:user_id) end + + # Count todos grouped by user_id and state, using an UNION query + # so we can utilize the partial indexes for each state. + def count_grouped_by_user_id_and_state + grouped_count = select(:user_id, 'count(id) AS count').group(:user_id) + + done = grouped_count.where(state: :done).select("'done' AS state") + pending = grouped_count.where(state: :pending).select("'pending' AS state") + union = unscoped.from_union([done, pending], remove_duplicates: false) + + connection.select_all(union).each_with_object({}) do |row, counts| + counts[[row['user_id'], row['state']]] = row['count'] + end + end end def resource_parent diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 611d9daa2fe..e473a6dc594 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -47,7 +47,7 @@ class TodoService yield target - todo_users.each(&:update_todos_count_cache) + Users::UpdateTodoCountCacheService.new(todo_users).execute if todo_users.present? end # When we reassign an assignable object (issuable, alert) we should: @@ -227,14 +227,16 @@ class TodoService users_with_pending_todos = pending_todos(users, attributes).pluck_user_id users.reject! { |user| users_with_pending_todos.include?(user.id) && Feature.disabled?(:multiple_todos, user) } - users.map do |user| + todos = users.map do |user| issue_type = attributes.delete(:issue_type) track_todo_creation(user, issue_type) - todo = Todo.create(attributes.merge(user_id: user.id)) - user.update_todos_count_cache - todo + Todo.create(attributes.merge(user_id: user.id)) end + + Users::UpdateTodoCountCacheService.new(users).execute + + todos end def new_issuable(issuable, author) diff --git a/app/services/users/update_todo_count_cache_service.rb b/app/services/users/update_todo_count_cache_service.rb new file mode 100644 index 00000000000..03ab66bd64a --- /dev/null +++ b/app/services/users/update_todo_count_cache_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Users + class UpdateTodoCountCacheService < BaseService + QUERY_BATCH_SIZE = 10 + + attr_reader :users + + # users - An array of User objects + def initialize(users) + @users = users + end + + def execute + users.each_slice(QUERY_BATCH_SIZE) do |users_batch| + todo_counts = Todo.for_user(users_batch).count_grouped_by_user_id_and_state + + users_batch.each do |user| + update_count_cache(user, todo_counts, :done) + update_count_cache(user, todo_counts, :pending) + end + end + end + + private + + def update_count_cache(user, todo_counts, state) + count = todo_counts.fetch([user.id, state.to_s], 0) + expiration_time = user.count_cache_validity_period + + Rails.cache.write(['users', user.id, "todos_#{state}_count"], count, expires_in: expiration_time) + end + end +end diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml index cb6c82e6d77..f5d28adfa66 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -17,7 +17,7 @@ aws_tip_commands_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'run-aws-commands-from-gitlab-cicd'), aws_tip_learn_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'aws'), protected_environment_variables_link: help_page_path('ci/variables/README', anchor: 'protect-a-cicd-variable'), - masked_environment_variables_link: help_page_path('ci/variables/README', anchor: 'mask-a-custom-variable'), + masked_environment_variables_link: help_page_path('ci/variables/README', anchor: 'mask-a-cicd-variable'), } } - if !@group && @project.group diff --git a/app/views/devise/mailer/password_change.html.haml b/app/views/devise/mailer/password_change.html.haml index 5ec515285f2..5c0219ea3ad 100644 --- a/app/views/devise/mailer/password_change.html.haml +++ b/app/views/devise/mailer/password_change.html.haml @@ -1,8 +1,5 @@ -= email_default_heading("Hello, #{@resource.name}!") += email_default_heading(_("Hello, %{name}!") % { name: @resource.name }) %p - The password for your GitLab account on - #{link_to(Gitlab.config.gitlab.url, Gitlab.config.gitlab.url)} - has successfully been changed. + = _('The password for your GitLab account on %{link_to_gitlab} has successfully been changed.').html_safe % { link_to_gitlab: link_to(Gitlab.config.gitlab.url, Gitlab.config.gitlab.url) } %p - If you did not initiate this change, please contact your administrator - immediately. + = _('If you did not initiate this change, please contact your administrator immediately.') diff --git a/app/views/devise/mailer/password_change.text.erb b/app/views/devise/mailer/password_change.text.erb index 95923d9f8de..6a8128186f5 100644 --- a/app/views/devise/mailer/password_change.text.erb +++ b/app/views/devise/mailer/password_change.text.erb @@ -1,7 +1,5 @@ -Hello, <%= @resource.name %>! +<%= _('Hello, %{name}!') % { name: @resource.name } %> -The password for your GitLab account on <%= Gitlab.config.gitlab.url %> -has successfully been changed. +<%= _('The password for your GitLab account on %{gitlab_url} has successfully been changed.') % { gitlab_url: Gitlab.config.gitlab.url } %> -If you did not initiate this change, please contact your administrator -immediately. +<%= _('If you did not initiate this change, please contact your administrator immediately.') %> diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml index 9c9d8f0b5c5..10c04423589 100644 --- a/app/views/devise/passwords/edit.html.haml +++ b/app/views/devise/passwords/edit.html.haml @@ -1,4 +1,4 @@ -= render 'devise/shared/tab_single', tab_title: 'Change your password' += render 'devise/shared/tab_single', tab_title: _('Change your password') .login-box .login-body = form_for(resource, as: resource_name, url: password_path(:user), html: { method: :put, class: 'gl-show-field-errors' }) do |f| @@ -6,16 +6,16 @@ = render "devise/shared/error_messages", resource: resource = f.hidden_field :reset_password_token .form-group - = f.label 'New password', for: "user_password" - = f.password_field :password, class: "form-control gl-form-input top", required: true, title: 'This field is required', data: { qa_selector: 'password_field'} + = f.label _('New password'), for: "user_password" + = f.password_field :password, class: "form-control gl-form-input top", required: true, title: _('This field is required.'), data: { qa_selector: 'password_field'} .form-group - = f.label 'Confirm new password', for: "user_password_confirmation" - = f.password_field :password_confirmation, class: "form-control gl-form-input bottom", title: 'This field is required', data: { qa_selector: 'password_confirmation_field' }, required: true + = f.label _('Confirm new password'), for: "user_password_confirmation" + = f.password_field :password_confirmation, class: "form-control gl-form-input bottom", title: _('This field is required.'), data: { qa_selector: 'password_confirmation_field' }, required: true .clearfix - = f.submit "Change your password", class: "gl-button btn btn-confirm", data: { qa_selector: 'change_password_button' } + = f.submit _("Change your password"), class: "gl-button btn btn-confirm", data: { qa_selector: 'change_password_button' } .clearfix.prepend-top-20 %p - %span.light Didn't receive a confirmation email? - = link_to "Request a new one", new_confirmation_path(:user) + %span.light= _("Didn't receive a confirmation email?") + = link_to _("Request a new one"), new_confirmation_path(:user) = render 'devise/shared/sign_in_link' diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml index cce0a3b926e..74f3e3e7e34 100644 --- a/app/views/devise/sessions/new.html.haml +++ b/app/views/devise/sessions/new.html.haml @@ -13,7 +13,7 @@ -# Show a message if none of the mechanisms above are enabled - if !password_authentication_enabled_for_web? && !ldap_sign_in_enabled? && !(omniauth_enabled? && devise_mapping.omniauthable?) %div - No authentication methods configured. + = _('No authentication methods configured.') - if allow_signup? %p.gl-mt-3 diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml index 404484cfb93..29bcb3c158b 100644 --- a/app/views/devise/sessions/two_factor.html.haml +++ b/app/views/devise/sessions/two_factor.html.haml @@ -1,5 +1,5 @@ %div - = render 'devise/shared/tab_single', tab_title: 'Two-Factor Authentication' + = render 'devise/shared/tab_single', tab_title: _('Two-Factor Authentication') .login-box .login-body - if @user.two_factor_otp_enabled? @@ -7,10 +7,10 @@ - resource_params = params[resource_name].presence || params = f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0) %div - = f.label 'Two-Factor Authentication code', name: :otp_attempt - = f.text_field :otp_attempt, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', title: 'This field is required.', data: { qa_selector: 'two_fa_code_field' } - %p.form-text.text-muted.hint Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes. + = f.label _('Two-Factor Authentication code'), name: :otp_attempt + = f.text_field :otp_attempt, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', title: _('This field is required.'), data: { qa_selector: 'two_fa_code_field' } + %p.form-text.text-muted.hint= _("Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes.") .prepend-top-20 - = f.submit "Verify code", class: "gl-button btn btn-confirm", data: { qa_selector: 'verify_code_button' } + = f.submit _("Verify code"), class: "gl-button btn btn-confirm", data: { qa_selector: 'verify_code_button' } - if @user.two_factor_webauthn_u2f_enabled? = render "authentication/authenticate", params: params, resource: resource, resource_name: resource_name, render_remember_me: true, target_path: new_user_session_path diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index 7c32d71078f..98b1c5adcb5 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -24,6 +24,7 @@ - lint_link_start = '<a href="%{url}">'.html_safe % { url: lint_link_url } = s_('You can also test your %{gitlab_ci_yml} in %{lint_link_start}CI Lint%{lint_link_end}').html_safe % { gitlab_ci_yml: '.gitlab-ci.yml', lint_link_start: lint_link_start, lint_link_end: '</a>'.html_safe } + #js-pipeline-notification{ data: { dag_doc_path: help_page_path('ci/yaml/README.md', anchor: 'needs') } } = render "projects/pipelines/with_tabs", pipeline: @pipeline, stages: @stages, pipeline_has_errors: pipeline_has_errors .js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json), metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: @project.namespace, project_id: @project, format: :json), pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline) } } diff --git a/bin/sidekiq-cluster b/bin/sidekiq-cluster index 2204a222b88..47f8e82d228 100755 --- a/bin/sidekiq-cluster +++ b/bin/sidekiq-cluster @@ -5,6 +5,7 @@ require 'optparse' require_relative '../lib/gitlab' require_relative '../lib/gitlab/utils' require_relative '../lib/gitlab/sidekiq_config/cli_methods' +require_relative '../lib/gitlab/sidekiq_config/worker_matcher' require_relative '../lib/gitlab/sidekiq_cluster' require_relative '../lib/gitlab/sidekiq_cluster/cli' diff --git a/changelogs/unreleased/321100-centralize-invalid-ci-state-in-authoring-section.yml b/changelogs/unreleased/321100-centralize-invalid-ci-state-in-authoring-section.yml new file mode 100644 index 00000000000..610532fc194 --- /dev/null +++ b/changelogs/unreleased/321100-centralize-invalid-ci-state-in-authoring-section.yml @@ -0,0 +1,5 @@ +--- +title: Centralize shared state in Authoring section +merge_request: 58790 +author: +type: changed diff --git a/changelogs/unreleased/326665_enable_projects_post_creation_worker.yml b/changelogs/unreleased/326665_enable_projects_post_creation_worker.yml new file mode 100644 index 00000000000..4c1424fdf11 --- /dev/null +++ b/changelogs/unreleased/326665_enable_projects_post_creation_worker.yml @@ -0,0 +1,5 @@ +--- +title: Create prometheus service asynchronously by default when creating a project +merge_request: 59273 +author: +type: changed diff --git a/changelogs/unreleased/Externalize-stings-in-password_change-html-haml.yml b/changelogs/unreleased/Externalize-stings-in-password_change-html-haml.yml new file mode 100644 index 00000000000..59e2604266c --- /dev/null +++ b/changelogs/unreleased/Externalize-stings-in-password_change-html-haml.yml @@ -0,0 +1,5 @@ +--- +title: Externalise strings in password_change files +merge_request: 58219 +author: nuwe1 +type: other diff --git a/changelogs/unreleased/Externalize-strings-in-passwords-edit-html-haml.yml b/changelogs/unreleased/Externalize-strings-in-passwords-edit-html-haml.yml new file mode 100644 index 00000000000..aa83ac87631 --- /dev/null +++ b/changelogs/unreleased/Externalize-strings-in-passwords-edit-html-haml.yml @@ -0,0 +1,5 @@ +--- +title: Externalize strings in passwords/edit.html.haml +merge_request: 58233 +author: nuwe1 +type: other diff --git a/changelogs/unreleased/Externalize-strings-in-sessions-new-html-haml.yml b/changelogs/unreleased/Externalize-strings-in-sessions-new-html-haml.yml new file mode 100644 index 00000000000..dafa53996c2 --- /dev/null +++ b/changelogs/unreleased/Externalize-strings-in-sessions-new-html-haml.yml @@ -0,0 +1,5 @@ +--- +title: Externalise strings in sessions/new.html.haml +merge_request: 58274 +author: nuwe1 +type: other diff --git a/changelogs/unreleased/Externalize-strings-in-sessions-two_factor-html-haml.yml b/changelogs/unreleased/Externalize-strings-in-sessions-two_factor-html-haml.yml new file mode 100644 index 00000000000..6bad42d3dc9 --- /dev/null +++ b/changelogs/unreleased/Externalize-strings-in-sessions-two_factor-html-haml.yml @@ -0,0 +1,5 @@ +--- +title: Externalize strings in sessions/two_factor.html.haml +merge_request: 58275 +author: nuwe1 +type: other diff --git a/changelogs/unreleased/bulk-update-user-todos-count-cache.yml b/changelogs/unreleased/bulk-update-user-todos-count-cache.yml new file mode 100644 index 00000000000..5412f194752 --- /dev/null +++ b/changelogs/unreleased/bulk-update-user-todos-count-cache.yml @@ -0,0 +1,5 @@ +--- +title: Avoid N+1 query when updating todo count cache +merge_request: 57622 +author: +type: performance diff --git a/changelogs/unreleased/issue-325831-make-searchQueryService-paramter-optional.yml b/changelogs/unreleased/issue-325831-make-searchQueryService-paramter-optional.yml new file mode 100644 index 00000000000..77866a0acad --- /dev/null +++ b/changelogs/unreleased/issue-325831-make-searchQueryService-paramter-optional.yml @@ -0,0 +1,5 @@ +--- +title: Make NuGet SearchQueryService q parameter optional +merge_request: 57654 +author: Huzaifa Iftikhar @huzaifaiftikhar +type: fixed diff --git a/changelogs/unreleased/mr-thread-comment-button.yml b/changelogs/unreleased/mr-thread-comment-button.yml new file mode 100644 index 00000000000..3ef6f72ce0b --- /dev/null +++ b/changelogs/unreleased/mr-thread-comment-button.yml @@ -0,0 +1,5 @@ +--- +title: Migrate Start Review button on MRs to use confirm variant +merge_request: 59523 +author: +type: changed diff --git a/changelogs/unreleased/ntepluhina-assignees-feature-flag.yml b/changelogs/unreleased/ntepluhina-assignees-feature-flag.yml new file mode 100644 index 00000000000..756d942d494 --- /dev/null +++ b/changelogs/unreleased/ntepluhina-assignees-feature-flag.yml @@ -0,0 +1,5 @@ +--- +title: Added feature flag to show/hide assignees GraphQL widget +merge_request: 59620 +author: +type: fixed diff --git a/changelogs/unreleased/remove_epics_index.yml b/changelogs/unreleased/remove_epics_index.yml new file mode 100644 index 00000000000..da3b1691b66 --- /dev/null +++ b/changelogs/unreleased/remove_epics_index.yml @@ -0,0 +1,5 @@ +--- +title: Remove redundant index from epics +merge_request: 59494 +author: +type: other diff --git a/changelogs/unreleased/tor-defect-single-quote-escapes.yml b/changelogs/unreleased/tor-defect-single-quote-escapes.yml new file mode 100644 index 00000000000..29e8aa54506 --- /dev/null +++ b/changelogs/unreleased/tor-defect-single-quote-escapes.yml @@ -0,0 +1,5 @@ +--- +title: Fix character escaping in Resolved By tooltips +merge_request: 59428 +author: +type: fixed diff --git a/config/feature_flags/development/issue_assignees_widget.yml b/config/feature_flags/development/issue_assignees_widget.yml new file mode 100644 index 00000000000..5c9b7df941f --- /dev/null +++ b/config/feature_flags/development/issue_assignees_widget.yml @@ -0,0 +1,8 @@ +--- +name: issue_assignees_widget +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/59620/ +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/328185 +milestone: '13.11' +type: development +group: group::project management +default_enabled: false diff --git a/config/feature_flags/development/projects_post_creation_worker.yml b/config/feature_flags/development/projects_post_creation_worker.yml index a844dc2b091..5d07e71f907 100644 --- a/config/feature_flags/development/projects_post_creation_worker.yml +++ b/config/feature_flags/development/projects_post_creation_worker.yml @@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/326665 milestone: '13.11' type: development group: group::source code -default_enabled: false +default_enabled: true diff --git a/config/metrics/license/20210216175602_installation_type.yml b/config/metrics/license/20210216175602_installation_type.yml index 577d0d502b3..ae5f26ff0a2 100644 --- a/config/metrics/license/20210216175602_installation_type.yml +++ b/config/metrics/license/20210216175602_installation_type.yml @@ -8,7 +8,7 @@ product_category: collection value_type: string status: data_available time_frame: none -data_source: +data_source: ruby distribution: - ce - ee @@ -16,4 +16,4 @@ tier: - free - premium - ultimate -skip_validation: true + diff --git a/config/metrics/counts_all/20210216174829_smtp_server.yml b/config/metrics/settings/20210216174829_smtp_server.yml index b60db7728c4..afee13f5534 100644 --- a/config/metrics/counts_all/20210216174829_smtp_server.yml +++ b/config/metrics/settings/20210216174829_smtp_server.yml @@ -2,13 +2,13 @@ key_path: mail.smtp_server description: The value of the SMTP server that is used product_section: growth -product_stage: -product_group: group::acquisition -product_category: +product_stage: growth +product_group: group::activation +product_category: onboarding value_type: number status: data_available time_frame: all -data_source: +data_source: ruby distribution: - ce - ee @@ -16,4 +16,3 @@ tier: - free - premium - ultimate -skip_validation: true diff --git a/db/migrate/20210415144538_remove_index_epics_on_group_id_from_epics.rb b/db/migrate/20210415144538_remove_index_epics_on_group_id_from_epics.rb new file mode 100644 index 00000000000..f691af4d8d2 --- /dev/null +++ b/db/migrate/20210415144538_remove_index_epics_on_group_id_from_epics.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class RemoveIndexEpicsOnGroupIdFromEpics < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + INDEX_NAME = 'index_epics_on_group_id' + + disable_ddl_transaction! + + def up + remove_concurrent_index_by_name :epics, INDEX_NAME + end + + def down + add_concurrent_index :epics, :group_id, name: INDEX_NAME + end +end diff --git a/db/schema_migrations/20210415144538 b/db/schema_migrations/20210415144538 new file mode 100644 index 00000000000..6b8e0d78b65 --- /dev/null +++ b/db/schema_migrations/20210415144538 @@ -0,0 +1 @@ +d237690af576fb5a85d984416dcca1936a140a10a9b6c968d3ff57419568fb8f
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index f6b52332062..4855d94dbfa 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -22603,8 +22603,6 @@ CREATE INDEX index_epics_on_due_date_sourcing_milestone_id ON epics USING btree CREATE INDEX index_epics_on_end_date ON epics USING btree (end_date); -CREATE INDEX index_epics_on_group_id ON epics USING btree (group_id); - CREATE UNIQUE INDEX index_epics_on_group_id_and_external_key ON epics USING btree (group_id, external_key) WHERE (external_key IS NOT NULL); CREATE UNIQUE INDEX index_epics_on_group_id_and_iid ON epics USING btree (group_id, iid); diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index bffedb97d6d..08f47e7fceb 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -3695,6 +3695,7 @@ Represents an iteration object. | `dueDate` | [`Time`](#time) | Timestamp of the iteration due date. | | `id` | [`ID!`](#id) | ID of the iteration. | | `iid` | [`ID!`](#id) | Internal ID of the iteration. | +| `iterationCadence` | [`IterationCadence!`](#iterationcadence) | Cadence of the iteration. | | `report` | [`TimeboxReport`](#timeboxreport) | Historically accurate report about the timebox. | | `scopedPath` | [`String`](#string) | Web path of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts. | | `scopedUrl` | [`String`](#string) | Web URL of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts. | diff --git a/doc/development/usage_ping/dictionary.md b/doc/development/usage_ping/dictionary.md index 6e54870e6b6..cb53d088907 100644 --- a/doc/development/usage_ping/dictionary.md +++ b/doc/development/usage_ping/dictionary.md @@ -6768,9 +6768,9 @@ Tiers: `premium`, `ultimate` The value of the SMTP server that is used -[YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/config/metrics/counts_all/20210216174829_smtp_server.yml) +[YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/config/metrics/settings/20210216174829_smtp_server.yml) -Group: `group::acquisition` +Group: `group::activation` Status: `data_available` diff --git a/doc/user/admin_area/custom_project_templates.md b/doc/user/admin_area/custom_project_templates.md index 26551d828bf..b4b33df37bf 100644 --- a/doc/user/admin_area/custom_project_templates.md +++ b/doc/user/admin_area/custom_project_templates.md @@ -16,7 +16,7 @@ Every project directly under the group namespace will be available to the user if they have access to them. For example: - Public projects, in the group will be available to every signed-in user, if all enabled [project features](../project/settings/index.md#sharing-and-permissions) - are set to **Everyone With Access**. + except for GitLab Pages are set to **Everyone With Access**. - Private projects will be available only if the user is a member of the project. Repository and database information that are copied over to each new project are diff --git a/doc/user/group/custom_project_templates.md b/doc/user/group/custom_project_templates.md index 813d2b8e265..016bda329b2 100644 --- a/doc/user/group/custom_project_templates.md +++ b/doc/user/group/custom_project_templates.md @@ -62,7 +62,7 @@ GitLab administrators can Within this section, you can configure the group where all the custom project templates are sourced. Every project _template_ directly under the group namespace is -available to every signed-in user, if all enabled [project features](../project/settings/index.md#sharing-and-permissions) are set to **Everyone With Access**. +available to every signed-in user, if all enabled [project features](../project/settings/index.md#sharing-and-permissions) except for GitLab Pages are set to **Everyone With Access**. However, private projects will be available only if the user is a member of the project. diff --git a/lib/api/concerns/packages/nuget_endpoints.rb b/lib/api/concerns/packages/nuget_endpoints.rb index 53b778875fc..5364eeb1880 100644 --- a/lib/api/concerns/packages/nuget_endpoints.rb +++ b/lib/api/concerns/packages/nuget_endpoints.rb @@ -95,7 +95,7 @@ module API # https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource params do - requires :q, type: String, desc: 'The search term' + optional :q, type: String, desc: 'The search term' optional :skip, type: Integer, desc: 'The number of results to skip', default: 0, regexp: NON_NEGATIVE_INTEGER_REGEX optional :take, type: Integer, desc: 'The number of results to return', default: Kaminari.config.default_per_page, regexp: POSITIVE_INTEGER_REGEX optional :prerelease, type: ::Grape::API::Boolean, desc: 'Include prerelease versions', default: true diff --git a/lib/gitlab/graphql/authorize/connection_filter_extension.rb b/lib/gitlab/graphql/authorize/connection_filter_extension.rb index 20526e19c2a..c75510df3e3 100644 --- a/lib/gitlab/graphql/authorize/connection_filter_extension.rb +++ b/lib/gitlab/graphql/authorize/connection_filter_extension.rb @@ -37,6 +37,8 @@ module Gitlab end def after_resolve(value:, context:, **rest) + return value if value.is_a?(GraphQL::Execution::Execute::Skip) + if @field.connection? redact_connection(value, context) elsif @field.type.list? diff --git a/lib/gitlab/sidekiq_cluster/cli.rb b/lib/gitlab/sidekiq_cluster/cli.rb index e471517c50a..9490d543dd1 100644 --- a/lib/gitlab/sidekiq_cluster/cli.rb +++ b/lib/gitlab/sidekiq_cluster/cli.rb @@ -53,11 +53,11 @@ module Gitlab 'You cannot specify --queue-selector and --experimental-queue-selector together' end - all_queues = SidekiqConfig::CliMethods.all_queues(@rails_path) - queue_names = SidekiqConfig::CliMethods.worker_queues(@rails_path) + worker_metadatas = SidekiqConfig::CliMethods.worker_metadatas(@rails_path) + worker_queues = SidekiqConfig::CliMethods.worker_queues(@rails_path) - queue_groups = argv.map do |queues| - next queue_names if queues == '*' + queue_groups = argv.map do |queues_or_query_string| + next worker_queues if queues_or_query_string == SidekiqConfig::WorkerMatcher::WILDCARD_MATCH # When using the queue query syntax, we treat each queue group # as a worker attribute query, and resolve the queues for the @@ -65,14 +65,14 @@ module Gitlab # Simplify with https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/646 if @queue_selector || @experimental_queue_selector - SidekiqConfig::CliMethods.query_workers(queues, all_queues) + SidekiqConfig::CliMethods.query_queues(queues_or_query_string, worker_metadatas) else - SidekiqConfig::CliMethods.expand_queues(queues.split(','), queue_names) + SidekiqConfig::CliMethods.expand_queues(queues_or_query_string.split(','), worker_queues) end end if @negate_queues - queue_groups.map! { |queues| queue_names - queues } + queue_groups.map! { |queues| worker_queues - queues } end if queue_groups.all?(&:empty?) diff --git a/lib/gitlab/sidekiq_config/cli_methods.rb b/lib/gitlab/sidekiq_config/cli_methods.rb index a256632bc12..8eef15f9ccb 100644 --- a/lib/gitlab/sidekiq_config/cli_methods.rb +++ b/lib/gitlab/sidekiq_config/cli_methods.rb @@ -12,35 +12,19 @@ module Gitlab # rubocop:disable Gitlab/ModuleWithInstanceVariables extend self + # The file names are misleading. Those files contain the metadata of the + # workers. They should be renamed to all_workers instead. + # https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/1018 QUEUE_CONFIG_PATHS = begin result = %w[app/workers/all_queues.yml] result << 'ee/app/workers/all_queues.yml' if Gitlab.ee? result end.freeze - QUERY_OR_OPERATOR = '|' - QUERY_AND_OPERATOR = '&' - QUERY_CONCATENATE_OPERATOR = ',' - QUERY_TERM_REGEX = %r{^(\w+)(!?=)([\w:#{QUERY_CONCATENATE_OPERATOR}]+)}.freeze + def worker_metadatas(rails_path = Rails.root.to_s) + @worker_metadatas ||= {} - QUERY_PREDICATES = { - feature_category: :to_sym, - has_external_dependencies: lambda { |value| value == 'true' }, - name: :to_s, - resource_boundary: :to_sym, - tags: :to_sym, - urgency: :to_sym - }.freeze - - QueryError = Class.new(StandardError) - InvalidTerm = Class.new(QueryError) - UnknownOperator = Class.new(QueryError) - UnknownPredicate = Class.new(QueryError) - - def all_queues(rails_path = Rails.root.to_s) - @worker_queues ||= {} - - @worker_queues[rails_path] ||= QUEUE_CONFIG_PATHS.flat_map do |path| + @worker_metadatas[rails_path] ||= QUEUE_CONFIG_PATHS.flat_map do |path| full_path = File.join(rails_path, path) File.exist?(full_path) ? YAML.load_file(full_path) : [] @@ -49,7 +33,7 @@ module Gitlab # rubocop:enable Gitlab/ModuleWithInstanceVariables def worker_queues(rails_path = Rails.root.to_s) - worker_names(all_queues(rails_path)) + worker_names(worker_metadatas(rails_path)) end def expand_queues(queues, all_queues = self.worker_queues) @@ -62,13 +46,18 @@ module Gitlab end end - def query_workers(query_string, queues) - worker_names(queues.select(&query_string_to_lambda(query_string))) + def query_queues(query_string, worker_metadatas) + matcher = SidekiqConfig::WorkerMatcher.new(query_string) + selected_metadatas = worker_metadatas.select do |worker_metadata| + matcher.match?(worker_metadata) + end + + worker_names(selected_metadatas) end def clear_memoization! - if instance_variable_defined?('@worker_queues') - remove_instance_variable('@worker_queues') + if instance_variable_defined?('@worker_metadatas') + remove_instance_variable('@worker_metadatas') end end @@ -77,53 +66,6 @@ module Gitlab def worker_names(workers) workers.map { |queue| queue[:name] } end - - def query_string_to_lambda(query_string) - or_clauses = query_string.split(QUERY_OR_OPERATOR).map do |and_clauses_string| - and_clauses_predicates = and_clauses_string.split(QUERY_AND_OPERATOR).map do |term| - predicate_for_term(term) - end - - lambda { |worker| and_clauses_predicates.all? { |predicate| predicate.call(worker) } } - end - - lambda { |worker| or_clauses.any? { |predicate| predicate.call(worker) } } - end - - def predicate_for_term(term) - match = term.match(QUERY_TERM_REGEX) - - raise InvalidTerm.new("Invalid term: #{term}") unless match - - _, lhs, op, rhs = *match - - predicate_for_op(op, predicate_factory(lhs, rhs.split(QUERY_CONCATENATE_OPERATOR))) - end - - def predicate_for_op(op, predicate) - case op - when '=' - predicate - when '!=' - lambda { |worker| !predicate.call(worker) } - else - # This is unreachable because InvalidTerm will be raised instead, but - # keeping it allows to guard against that changing in future. - raise UnknownOperator.new("Unknown operator: #{op}") - end - end - - def predicate_factory(lhs, values) - values_block = QUERY_PREDICATES[lhs.to_sym] - - raise UnknownPredicate.new("Unknown predicate: #{lhs}") unless values_block - - lambda do |queue| - comparator = Array(queue[lhs.to_sym]).to_set - - values.map(&values_block).to_set.intersect?(comparator) - end - end end end end diff --git a/lib/gitlab/sidekiq_config/worker_matcher.rb b/lib/gitlab/sidekiq_config/worker_matcher.rb new file mode 100644 index 00000000000..fe5ac10c65a --- /dev/null +++ b/lib/gitlab/sidekiq_config/worker_matcher.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Gitlab + module SidekiqConfig + class WorkerMatcher + WILDCARD_MATCH = '*' + QUERY_OR_OPERATOR = '|' + QUERY_AND_OPERATOR = '&' + QUERY_CONCATENATE_OPERATOR = ',' + QUERY_TERM_REGEX = %r{^(\w+)(!?=)([\w:#{QUERY_CONCATENATE_OPERATOR}]+)}.freeze + + QUERY_PREDICATES = { + feature_category: :to_sym, + has_external_dependencies: lambda { |value| value == 'true' }, + name: :to_s, + resource_boundary: :to_sym, + tags: :to_sym, + urgency: :to_sym + }.freeze + + QueryError = Class.new(StandardError) + InvalidTerm = Class.new(QueryError) + UnknownOperator = Class.new(QueryError) + UnknownPredicate = Class.new(QueryError) + + def initialize(query_string) + @match_lambda = query_string_to_lambda(query_string) + end + + def match?(worker_metadata) + @match_lambda.call(worker_metadata) + end + + private + + def query_string_to_lambda(query_string) + return lambda { |_worker| true } if query_string.strip == WILDCARD_MATCH + + or_clauses = query_string.split(QUERY_OR_OPERATOR).map do |and_clauses_string| + and_clauses_predicates = and_clauses_string.split(QUERY_AND_OPERATOR).map do |term| + predicate_for_term(term) + end + + lambda { |worker| and_clauses_predicates.all? { |predicate| predicate.call(worker) } } + end + + lambda { |worker| or_clauses.any? { |predicate| predicate.call(worker) } } + end + + def predicate_for_term(term) + match = term.match(QUERY_TERM_REGEX) + + raise InvalidTerm.new("Invalid term: #{term}") unless match + + _, lhs, op, rhs = *match + + predicate_for_op(op, predicate_factory(lhs, rhs.split(QUERY_CONCATENATE_OPERATOR))) + end + + def predicate_for_op(op, predicate) + case op + when '=' + predicate + when '!=' + lambda { |worker| !predicate.call(worker) } + else + # This is unreachable because InvalidTerm will be raised instead, but + # keeping it allows to guard against that changing in future. + raise UnknownOperator.new("Unknown operator: #{op}") + end + end + + def predicate_factory(lhs, values) + values_block = QUERY_PREDICATES[lhs.to_sym] + + raise UnknownPredicate.new("Unknown predicate: #{lhs}") unless values_block + + lambda do |queue| + comparator = Array(queue[lhs.to_sym]).to_set + + values.map(&values_block).to_set.intersect?(comparator) + end + end + end + end +end diff --git a/lib/tasks/brakeman.rake b/lib/tasks/brakeman.rake deleted file mode 100644 index 44d2071751f..00000000000 --- a/lib/tasks/brakeman.rake +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -desc 'Security check via brakeman' -task :brakeman do - # We get 0 warnings at level 'w3' but we would like to reach 'w2'. Merge - # requests are welcome! - if system(*%w(brakeman --no-progress --skip-files lib/backup/repository.rb -w3 -z)) - puts 'Security check succeed' - else - puts 'Security check failed' - exit 1 - end -end diff --git a/lib/tasks/gitlab/test.rake b/lib/tasks/gitlab/test.rake deleted file mode 100644 index a83ba69bc75..00000000000 --- a/lib/tasks/gitlab/test.rake +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -namespace :gitlab do - desc "GitLab | Run all tests" - task :test do - cmds = [ - %w(rake brakeman), - %w(rake rubocop), - %w(rake spec), - %w(rake karma) - ] - - cmds.each do |cmd| - system({ 'RAILS_ENV' => 'test', 'force' => 'yes' }, *cmd) || raise("#{cmd} failed!") - end - end -end diff --git a/lib/tasks/test.rake b/lib/tasks/test.rake index b24817468c6..c4eb9450b31 100644 --- a/lib/tasks/test.rake +++ b/lib/tasks/test.rake @@ -2,7 +2,16 @@ Rake::Task["test"].clear -desc "GitLab | Run all tests" +desc "GitLab | List rake tasks for tests" task :test do - Rake::Task["gitlab:test"].invoke + puts "Running the full GitLab test suite takes significant time to pass. We recommend using one of the following spec tasks:\n\n" + + spec_tasks = Rake::Task.tasks.select { |t| t.name.start_with?('spec:') } + longest_task_name = spec_tasks.map { |t| t.name.size }.max + + spec_tasks.each do |task| + puts "#{"%-#{longest_task_name}s" % task.name} | #{task.full_comment}" + end + + puts "\nLearn more at https://docs.gitlab.com/ee/development/rake_tasks.html#run-tests." end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index b6200d61b69..cd3e66e6cea 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8305,6 +8305,9 @@ msgstr "" msgid "Confirm" msgstr "" +msgid "Confirm new password" +msgstr "" + msgid "Confirm your account" msgstr "" @@ -11220,6 +11223,9 @@ msgstr "" msgid "DevopsReport|Score" msgstr "" +msgid "Didn't receive a confirmation email?" +msgstr "" + msgid "Diff content limits" msgstr "" @@ -16130,6 +16136,9 @@ msgstr "" msgid "If you add %{codeStart}needs%{codeEnd} to jobs in your pipeline you'll be able to view the %{codeStart}needs%{codeEnd} relationships between jobs in this tab as a %{linkStart}Directed Acyclic Graph (DAG)%{linkEnd}." msgstr "" +msgid "If you did not initiate this change, please contact your administrator immediately." +msgstr "" + msgid "If you did not recently sign in, you should immediately %{password_link_start}change your password%{password_link_end}." msgstr "" @@ -23150,6 +23159,18 @@ msgstr "" msgid "PipelineCharts|Total:" msgstr "" +msgid "PipelineEditor|The CI/CD configuration is continuously validated. Errors and warnings are displayed when the CI/CD configuration file is not empty." +msgstr "" + +msgid "PipelineEditor|The merged YAML view is displayed when the CI/CD configuration file has valid syntax." +msgstr "" + +msgid "PipelineEditor|The pipeline visualization is displayed when the CI/CD configuration file has valid syntax." +msgstr "" + +msgid "PipelineEditor|This tab will be usable when the CI/CD configuration file is populated with valid syntax." +msgstr "" + msgid "PipelineScheduleIntervalPattern|Custom (%{linkStart}Cron syntax%{linkEnd})" msgstr "" @@ -25700,6 +25721,9 @@ msgstr "" msgid "Protocol" msgstr "" +msgid "Provide feedback" +msgstr "" + msgid "Provider" msgstr "" @@ -26789,6 +26813,9 @@ msgstr "" msgid "Request Access" msgstr "" +msgid "Request a new one" +msgstr "" + msgid "Request details" msgstr "" @@ -31272,6 +31299,12 @@ msgstr "" msgid "The password for the Jenkins server." msgstr "" +msgid "The password for your GitLab account on %{gitlab_url} has successfully been changed." +msgstr "" + +msgid "The password for your GitLab account on %{link_to_gitlab} has successfully been changed." +msgstr "" + msgid "The phase of the development lifecycle." msgstr "" @@ -31425,9 +31458,6 @@ msgstr "" msgid "The value of the provided variable exceeds the %{count} character limit" msgstr "" -msgid "The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax." -msgstr "" - msgid "The vulnerability is known, and has not been remediated or mitigated, but is considered to be an acceptable business risk." msgstr "" @@ -31563,6 +31593,9 @@ msgstr "" msgid "There was a problem communicating with your device." msgstr "" +msgid "There was a problem dismissing this notification." +msgstr "" + msgid "There was a problem fetching branches." msgstr "" @@ -34497,6 +34530,9 @@ msgstr "" msgid "Verify SAML Configuration" msgstr "" +msgid "Verify code" +msgstr "" + msgid "Verify configuration" msgstr "" @@ -34588,6 +34624,9 @@ msgstr "" msgid "View job" msgstr "" +msgid "View job dependencies in the pipeline graph!" +msgstr "" + msgid "View job log" msgstr "" @@ -35778,6 +35817,9 @@ msgstr "" msgid "You can now export your security dashboard to a CSV report." msgstr "" +msgid "You can now group jobs in the pipeline graph based on which jobs are configured to run first, if you use the %{codeStart}needs:%{codeEnd} keyword to establish job dependencies in your CI/CD pipelines. %{linkStart}Learn how to speed up your pipeline with needs.%{linkEnd}" +msgstr "" + msgid "You can now submit a merge request to get this change into the original branch." msgstr "" @@ -36165,7 +36207,7 @@ msgstr "" msgid "Your %{strong}%{plan_name}%{strong_close} subscription will expire on %{strong}%{expires_on}%{strong_close}. After that, you will not be able to create issues or merge requests as well as many other features." msgstr "" -msgid "Your CI configuration file is invalid." +msgid "Your CI/CD configuration syntax is invalid. View Lint tab for more details." msgstr "" msgid "Your CSV export has started. It will be emailed to %{email} when complete." diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb index d3191af5d96..04b4caa52fe 100644 --- a/spec/features/issues/issue_sidebar_spec.rb +++ b/spec/features/issues/issue_sidebar_spec.rb @@ -30,116 +30,198 @@ RSpec.describe 'Issue Sidebar' do let(:user2) { create(:user) } let(:issue2) { create(:issue, project: project, author: user2) } - context 'when a privileged user can invite' do - it 'shows a link for inviting members and launches invite modal' do - project.add_maintainer(user) - visit_issue(project, issue2) + context 'when GraphQL assignees widget feature flag is disabled' do + before do + stub_feature_flags(issue_assignees_widget: false) + end + + include_examples 'issuable invite members experiments' do + let(:issuable_path) { project_issue_path(project, issue2) } + end - open_assignees_dropdown + context 'when user is a developer' do + before do + project.add_developer(user) + visit_issue(project, issue2) + + find('.block.assignee .edit-link').click + wait_for_requests + end - page.within '.dropdown-menu-user' do - expect(page).to have_link('Invite members') - expect(page).to have_selector('[data-track-event="click_invite_members"]') - expect(page).to have_selector('[data-track-label="edit_assignee"]') + it 'shows author in assignee dropdown' do + page.within '.dropdown-menu-user' do + expect(page).to have_content(user2.name) + end end - click_link 'Invite members' + it 'shows author when filtering assignee dropdown' do + page.within '.dropdown-menu-user' do + find('.dropdown-input-field').set(user2.name) - expect(page).to have_content("You're inviting members to the") - end - end + wait_for_requests - context 'when invite_members_version_b experiment is enabled' do - before do - stub_experiment_for_subject(invite_members_version_b: true) - end + expect(page).to have_content(user2.name) + end + end + + it 'assigns yourself' do + find('.block.assignee .dropdown-menu-toggle').click - it 'shows a link for inviting members and follows through to modal' do - project.add_developer(user) - visit_issue(project, issue2) + click_button 'assign yourself' - open_assignees_dropdown + wait_for_requests + + find('.block.assignee .edit-link').click - page.within '.dropdown-menu-user' do - expect(page).to have_link('Invite members', href: '#') - expect(page).to have_selector('[data-track-event="click_invite_members_version_b"]') - expect(page).to have_selector('[data-track-label="edit_assignee"]') + page.within '.dropdown-menu-user' do + expect(page.find('.dropdown-header')).to be_visible + expect(page.find('.dropdown-menu-user-link.is-active')).to have_content(user.name) + end end - click_link 'Invite members' + it 'keeps your filtered term after filtering and dismissing the dropdown' do + find('.dropdown-input-field').set(user2.name) - expect(page).to have_content("Oops, this feature isn't ready yet") - end - end + wait_for_requests + + page.within '.dropdown-menu-user' do + expect(page).not_to have_content 'Unassigned' + click_link user2.name + end - context 'when invite_members_version_b experiment is disabled' do - it 'shows author in assignee dropdown and no invite link' do - project.add_developer(user) - visit_issue(project, issue2) + find('.js-right-sidebar').click + find('.block.assignee .edit-link').click - open_assignees_dropdown + expect(page.all('.dropdown-menu-user li').length).to eq(1) + expect(find('.dropdown-input-field').value).to eq(user2.name) + end + + it 'shows label text as "Apply" when assignees are changed' do + project.add_developer(user) + visit_issue(project, issue2) + + find('.block.assignee .edit-link').click + wait_for_requests - page.within '.dropdown-menu-user' do - expect(page).not_to have_link('Invite members') + click_on 'Unassigned' + + expect(page).to have_link('Apply') end end end - context 'when user is a developer' do - before do - project.add_developer(user) - visit_issue(project, issue2) - end + context 'when GraphQL assignees widget feature flag is enabled' do + context 'when a privileged user can invite' do + it 'shows a link for inviting members and launches invite modal' do + project.add_maintainer(user) + visit_issue(project, issue2) + + open_assignees_dropdown - it 'shows author in assignee dropdown' do - open_assignees_dropdown + page.within '.dropdown-menu-user' do + expect(page).to have_link('Invite members') + expect(page).to have_selector('[data-track-event="click_invite_members"]') + expect(page).to have_selector('[data-track-label="edit_assignee"]') + end + + click_link 'Invite members' - page.within '.dropdown-menu-user' do - expect(page).to have_content(user2.name) + expect(page).to have_content("You're inviting members to the") end end - it 'shows author when filtering assignee dropdown' do - open_assignees_dropdown + context 'when invite_members_version_b experiment is enabled' do + before do + stub_experiment_for_subject(invite_members_version_b: true) + end + + it 'shows a link for inviting members and follows through to modal' do + project.add_developer(user) + visit_issue(project, issue2) - page.within '.dropdown-menu-user' do - find('.js-dropdown-input-field').find('input').set(user2.name) + open_assignees_dropdown - wait_for_requests + page.within '.dropdown-menu-user' do + expect(page).to have_link('Invite members', href: '#') + expect(page).to have_selector('[data-track-event="click_invite_members_version_b"]') + expect(page).to have_selector('[data-track-label="edit_assignee"]') + end - expect(page).to have_content(user2.name) + click_link 'Invite members' + + expect(page).to have_content("Oops, this feature isn't ready yet") end end - it 'assigns yourself' do - click_button 'assign yourself' - wait_for_requests + context 'when invite_members_version_b experiment is disabled' do + it 'shows author in assignee dropdown and no invite link' do + project.add_developer(user) + visit_issue(project, issue2) + + open_assignees_dropdown - page.within '.assignee' do - expect(page).to have_content(user.name) + page.within '.dropdown-menu-user' do + expect(page).not_to have_link('Invite members') + end end end - it 'keeps your filtered term after filtering and dismissing the dropdown' do - open_assignees_dropdown + context 'when user is a developer' do + before do + project.add_developer(user) + visit_issue(project, issue2) + end - find('.js-dropdown-input-field').find('input').set(user2.name) - wait_for_requests + it 'shows author in assignee dropdown' do + open_assignees_dropdown - page.within '.dropdown-menu-user' do - expect(page).not_to have_content 'Unassigned' - click_link user2.name + page.within '.dropdown-menu-user' do + expect(page).to have_content(user2.name) + end end - find('.js-right-sidebar').click + it 'shows author when filtering assignee dropdown' do + open_assignees_dropdown + + page.within '.dropdown-menu-user' do + find('.js-dropdown-input-field').find('input').set(user2.name) + + wait_for_requests - open_assignees_dropdown + expect(page).to have_content(user2.name) + end + end + + it 'assigns yourself' do + click_button 'assign yourself' + wait_for_requests - page.within('.assignee') do - expect(page.all('[data-testid="selected-participant"]').length).to eq(1) + page.within '.assignee' do + expect(page).to have_content(user.name) + end end - expect(find('.js-dropdown-input-field').find('input').value).to eq(user2.name) + it 'keeps your filtered term after filtering and dismissing the dropdown' do + open_assignees_dropdown + + find('.js-dropdown-input-field').find('input').set(user2.name) + wait_for_requests + + page.within '.dropdown-menu-user' do + expect(page).not_to have_content 'Unassigned' + click_link user2.name + end + + find('.js-right-sidebar').click + + open_assignees_dropdown + + page.within('.assignee') do + expect(page.all('[data-testid="selected-participant"]').length).to eq(1) + end + + expect(find('.js-dropdown-input-field').find('input').value).to eq(user2.name) + end end end end diff --git a/spec/features/issues/user_edits_issue_spec.rb b/spec/features/issues/user_edits_issue_spec.rb index 676484b5c09..1bbb96ff479 100644 --- a/spec/features/issues/user_edits_issue_spec.rb +++ b/spec/features/issues/user_edits_issue_spec.rb @@ -167,79 +167,165 @@ RSpec.describe "Issues > User edits issue", :js do end describe 'update assignee' do - context 'by authorized user' do - it 'allows user to select unassigned' do - visit project_issue_path(project, issue) + context 'when GraphQL assignees widget feature flag is disabled' do + before do + stub_feature_flags(issue_assignees_widget: false) + end - page.within('.assignee') do - expect(page).to have_content "#{user.name}" + context 'by authorized user' do + def close_dropdown_menu_if_visible + find('.dropdown-menu-toggle', visible: :all).tap do |toggle| + toggle.click if toggle.visible? + end + end - click_button('Edit') - wait_for_requests + it 'allows user to select unassigned' do + visit project_issue_path(project, issue) - find('[data-testid="unassign"]').click - find('[data-testid="title"]').click - wait_for_requests + page.within('.assignee') do + expect(page).to have_content "#{user.name}" + + click_link 'Edit' + click_link 'Unassigned' + first('.title').click - expect(page).to have_content 'None - assign yourself' + expect(page).to have_content 'None - assign yourself' + end end - end - it 'allows user to select an assignee' do - issue2 = create(:issue, project: project, author: user) - visit project_issue_path(project, issue2) + it 'allows user to select an assignee' do + issue2 = create(:issue, project: project, author: user) + visit project_issue_path(project, issue2) - page.within('.assignee') do - expect(page).to have_content "None" - click_button('Edit') - wait_for_requests + page.within('.assignee') do + expect(page).to have_content "None" + end + + page.within '.assignee' do + click_link 'Edit' + end + + page.within '.dropdown-menu-user' do + click_link user.name + end + + page.within('.assignee') do + expect(page).to have_content user.name + end + end + + it 'allows user to unselect themselves' do + issue2 = create(:issue, project: project, author: user, assignees: [user]) + + visit project_issue_path(project, issue2) + + page.within '.assignee' do + expect(page).to have_content user.name + + click_link 'Edit' + click_link user.name + + close_dropdown_menu_if_visible + + page.within '.value .assign-yourself' do + expect(page).to have_content "None" + end + end end + end - page.within '.dropdown-menu-user' do - click_link user.name + context 'by unauthorized user' do + let(:guest) { create(:user) } + + before do + project.add_guest(guest) end - page.within('.assignee') do - find('[data-testid="title"]').click - wait_for_requests + it 'shows assignee text' do + sign_out(:user) + sign_in(guest) - expect(page).to have_content user.name + visit project_issue_path(project, issue) + expect(page).to have_content issue.assignees.first.name end end + end - it 'allows user to unselect themselves' do - issue2 = create(:issue, project: project, author: user, assignees: [user]) + context 'when GraphQL assignees widget feature flag is enabled' do + context 'by authorized user' do + it 'allows user to select unassigned' do + visit project_issue_path(project, issue) - visit project_issue_path(project, issue2) + page.within('.assignee') do + expect(page).to have_content "#{user.name}" - page.within '.assignee' do - expect(page).to have_content user.name + click_button('Edit') + wait_for_requests - click_button('Edit') - wait_for_requests - click_link user.name + find('[data-testid="unassign"]').click + find('[data-testid="title"]').click + wait_for_requests - find('[data-testid="title"]').click - wait_for_requests + expect(page).to have_content 'None - assign yourself' + end + end - expect(page).to have_content "None" + it 'allows user to select an assignee' do + issue2 = create(:issue, project: project, author: user) + visit project_issue_path(project, issue2) + + page.within('.assignee') do + expect(page).to have_content "None" + click_button('Edit') + wait_for_requests + end + + page.within '.dropdown-menu-user' do + click_link user.name + end + + page.within('.assignee') do + find('[data-testid="title"]').click + wait_for_requests + + expect(page).to have_content user.name + end end - end - end - context 'by unauthorized user' do - let(:guest) { create(:user) } + it 'allows user to unselect themselves' do + issue2 = create(:issue, project: project, author: user, assignees: [user]) - before do - project.add_guest(guest) + visit project_issue_path(project, issue2) + + page.within '.assignee' do + expect(page).to have_content user.name + + click_button('Edit') + wait_for_requests + click_link user.name + + find('[data-testid="title"]').click + wait_for_requests + + expect(page).to have_content "None" + end + end end - it 'shows assignee text' do - sign_out(:user) - sign_in(guest) + context 'by unauthorized user' do + let(:guest) { create(:user) } - visit project_issue_path(project, issue) - expect(page).to have_content issue.assignees.first.name + before do + project.add_guest(guest) + end + + it 'shows assignee text' do + sign_out(:user) + sign_in(guest) + + visit project_issue_path(project, issue) + expect(page).to have_content issue.assignees.first.name + end end end end diff --git a/spec/features/projects/settings/forked_project_settings_spec.rb b/spec/features/projects/settings/forked_project_settings_spec.rb index e98db890b02..a84516e19f9 100644 --- a/spec/features/projects/settings/forked_project_settings_spec.rb +++ b/spec/features/projects/settings/forked_project_settings_spec.rb @@ -25,7 +25,8 @@ RSpec.describe 'Projects > Settings > For a forked project', :js do fill_in('confirm_name_input', with: forked_project.name) click_button('Confirm') - expect(page).to have_content('The fork relationship has been removed.') + wait_for_requests + expect(forked_project.reload.forked?).to be_falsy end end diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb index 85da1db354a..c18b0f2688b 100644 --- a/spec/features/projects_spec.rb +++ b/spec/features/projects_spec.rb @@ -174,26 +174,6 @@ RSpec.describe 'Project' do end end - describe 'remove forked relationship', :js do - let(:user) { create(:user) } - let(:project) { fork_project(create(:project, :public), user, namespace: user.namespace) } - - before do - sign_in user - visit edit_project_path(project) - end - - it 'removes fork', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/327817' do - expect(page).to have_content 'Remove fork relationship' - - remove_with_confirm('Remove fork relationship', project.path) - - expect(page).to have_content 'The fork relationship has been removed.' - expect(project.reload.forked?).to be_falsey - expect(page).not_to have_content 'Remove fork relationship' - end - end - describe 'showing information about source of a project fork' do let(:user) { create(:user) } let(:base_project) { create(:project, :public, :repository) } diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js index cc41088e21e..ecce854b00a 100644 --- a/spec/frontend/notes/components/note_actions_spec.js +++ b/spec/frontend/notes/components/note_actions_spec.js @@ -151,6 +151,22 @@ describe('noteActions', () => { const assignUserButton = wrapper.find('[data-testid="assign-user"]'); expect(assignUserButton.exists()).toBe(false); }); + + it('should render the correct (unescaped) name in the Resolved By tooltip', () => { + const complexUnescapedName = 'This is a Ǝ\'𝞓\'E "cat"?'; + wrapper = mountNoteActions({ + ...props, + canResolve: true, + isResolving: false, + isResolved: true, + resolvedBy: { + name: complexUnescapedName, + }, + }); + + const { resolveButton } = wrapper.vm.$refs; + expect(resolveButton.$el.getAttribute('title')).toBe(`Resolved by ${complexUnescapedName}`); + }); }); }); diff --git a/spec/frontend/pipeline_editor/components/editor/ci_config_merged_preview_spec.js b/spec/frontend/pipeline_editor/components/editor/ci_config_merged_preview_spec.js index ebc02a64dc7..fb191fccb0d 100644 --- a/spec/frontend/pipeline_editor/components/editor/ci_config_merged_preview_spec.js +++ b/spec/frontend/pipeline_editor/components/editor/ci_config_merged_preview_spec.js @@ -1,9 +1,8 @@ -import { GlAlert, GlIcon } from '@gitlab/ui'; +import { GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { EDITOR_READY_EVENT } from '~/editor/constants'; import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue'; -import { INVALID_CI_CONFIG } from '~/pipelines/constants'; import { mockLintResponse, mockCiConfigPath } from '../../mock_data'; describe('Text editor component', () => { @@ -32,7 +31,6 @@ describe('Text editor component', () => { }); }; - const findAlert = () => wrapper.findComponent(GlAlert); const findIcon = () => wrapper.findComponent(GlIcon); const findEditor = () => wrapper.findComponent(MockEditorLite); @@ -40,24 +38,9 @@ describe('Text editor component', () => { wrapper.destroy(); }); - describe('when status is invalid', () => { - beforeEach(() => { - createComponent({ props: { isValid: false } }); - }); - - it('show an error message', () => { - expect(findAlert().exists()).toBe(true); - expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[INVALID_CI_CONFIG]); - }); - - it('hides the editor', () => { - expect(findEditor().exists()).toBe(false); - }); - }); - describe('when status is valid', () => { beforeEach(() => { - createComponent({ props: { isValid: true } }); + createComponent(); }); it('shows an information message that the section is not editable', () => { diff --git a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js index f59239b59f5..eba853180cd 100644 --- a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js +++ b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js @@ -4,9 +4,12 @@ import { nextTick } from 'vue'; import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue'; import CiLint from '~/pipeline_editor/components/lint/ci_lint.vue'; import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue'; +import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue'; import { + EDITOR_APP_STATUS_EMPTY, EDITOR_APP_STATUS_ERROR, EDITOR_APP_STATUS_LOADING, + EDITOR_APP_STATUS_INVALID, EDITOR_APP_STATUS_VALID, } from '~/pipeline_editor/constants'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; @@ -44,6 +47,7 @@ describe('Pipeline editor tabs component', () => { provide: { ...mockProvide, ...provide }, stubs: { TextEditor: MockTextEditor, + EditorTab, }, }); }; @@ -192,4 +196,24 @@ describe('Pipeline editor tabs component', () => { }); }); }); + + describe('show tab content based on status', () => { + it.each` + appStatus | editor | viz | lint | merged + ${undefined} | ${true} | ${true} | ${true} | ${true} + ${EDITOR_APP_STATUS_EMPTY} | ${true} | ${false} | ${false} | ${false} + ${EDITOR_APP_STATUS_INVALID} | ${true} | ${false} | ${true} | ${false} + ${EDITOR_APP_STATUS_VALID} | ${true} | ${true} | ${true} | ${true} + `( + 'when status is $appStatus, we show - editor:$editor | viz:$viz | lint:$lint | merged:$merged ', + ({ appStatus, editor, viz, lint, merged }) => { + createComponent({ appStatus }); + + expect(findTextEditor().exists()).toBe(editor); + expect(findPipelineGraph().exists()).toBe(viz); + expect(findCiLint().exists()).toBe(lint); + expect(findMergedPreview().exists()).toBe(merged); + }, + ); + }); }); diff --git a/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js b/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js index 291468c5229..8def83d578b 100644 --- a/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js +++ b/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js @@ -1,12 +1,15 @@ -import { GlTabs } from '@gitlab/ui'; +import { GlAlert, GlTabs } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; - import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue'; const mockContent1 = 'MOCK CONTENT 1'; const mockContent2 = 'MOCK CONTENT 2'; +const MockEditorLite = { + template: '<div>EDITOR</div>', +}; + describe('~/pipeline_editor/components/ui/editor_tab.vue', () => { let wrapper; let mockChildMounted = jest.fn(); @@ -37,22 +40,34 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => { `, }; - const createWrapper = () => { + const createMockedWrapper = () => { wrapper = mount(MockTabbedContent); }; + const createWrapper = ({ props } = {}) => { + wrapper = mount(EditorTab, { + propsData: props, + slots: { + default: MockEditorLite, + }, + }); + }; + + const findSlotComponent = () => wrapper.findComponent(MockEditorLite); + const findAlert = () => wrapper.findComponent(GlAlert); + beforeEach(() => { mockChildMounted = jest.fn(); }); it('tabs are mounted lazily', async () => { - createWrapper(); + createMockedWrapper(); expect(mockChildMounted).toHaveBeenCalledTimes(0); }); it('first tab is only mounted after nextTick', async () => { - createWrapper(); + createMockedWrapper(); await nextTick(); @@ -60,6 +75,36 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => { expect(mockChildMounted).toHaveBeenCalledWith(mockContent1); }); + describe('showing the tab content depending on `isEmpty` and `isInvalid`', () => { + it.each` + isEmpty | isInvalid | showSlotComponent | text + ${undefined} | ${undefined} | ${true} | ${'renders'} + ${false} | ${false} | ${true} | ${'renders'} + ${undefined} | ${true} | ${false} | ${'hides'} + ${true} | ${false} | ${false} | ${'hides'} + ${false} | ${true} | ${false} | ${'hides'} + `( + '$text the slot component when isEmpty:$isEmpty and isInvalid:$isInvalid', + ({ isEmpty, isInvalid, showSlotComponent }) => { + createWrapper({ + props: { isEmpty, isInvalid }, + }); + expect(findSlotComponent().exists()).toBe(showSlotComponent); + expect(findAlert().exists()).toBe(!showSlotComponent); + }, + ); + + it('can have a custom empty message', () => { + const text = 'my custom alert message'; + createWrapper({ props: { isEmpty: true, emptyMessage: text } }); + + const alert = findAlert(); + + expect(alert.exists()).toBe(true); + expect(alert.text()).toBe(text); + }); + }); + describe('user interaction', () => { const clickTab = async (testid) => { wrapper.find(`[data-testid="${testid}"]`).trigger('click'); @@ -67,7 +112,7 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => { }; beforeEach(() => { - createWrapper(); + createMockedWrapper(); }); it('mounts a tab once after selecting it', async () => { diff --git a/spec/frontend/pipelines/notification/pipeline_notification_spec.js b/spec/frontend/pipelines/notification/pipeline_notification_spec.js new file mode 100644 index 00000000000..79aa337ba9d --- /dev/null +++ b/spec/frontend/pipelines/notification/pipeline_notification_spec.js @@ -0,0 +1,79 @@ +import { GlBanner } from '@gitlab/ui'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import PipelineNotification from '~/pipelines/components/notification/pipeline_notification.vue'; +import getUserCallouts from '~/pipelines/graphql/queries/get_user_callouts.query.graphql'; + +describe('Pipeline notification', () => { + const localVue = createLocalVue(); + + let wrapper; + const dagDocPath = 'my/dag/path'; + + const createWrapper = (apolloProvider) => { + return shallowMount(PipelineNotification, { + localVue, + provide: { + dagDocPath, + }, + apolloProvider, + }); + }; + + const createWrapperWithApollo = async ({ callouts = [], isLoading = false } = {}) => { + localVue.use(VueApollo); + + const mappedCallouts = callouts.map((callout) => { + return { featureName: callout, __typename: 'UserCallout' }; + }); + + const mockCalloutsResponse = { + data: { + currentUser: { + id: 45, + __typename: 'User', + callouts: { + id: 5, + __typename: 'UserCalloutConnection', + nodes: mappedCallouts, + }, + }, + }, + }; + const getUserCalloutsHandler = jest.fn().mockResolvedValue(mockCalloutsResponse); + const requestHandlers = [[getUserCallouts, getUserCalloutsHandler]]; + + const apolloWrapper = createWrapper(createMockApollo(requestHandlers)); + if (!isLoading) { + await nextTick(); + } + + return apolloWrapper; + }; + + const findBanner = () => wrapper.findComponent(GlBanner); + + afterEach(() => { + wrapper.destroy(); + }); + + it('shows the banner if the user has never seen it', async () => { + wrapper = await createWrapperWithApollo({ callouts: ['random'] }); + + expect(findBanner().exists()).toBe(true); + }); + + it('does not show the banner while the user callout query is loading', async () => { + wrapper = await createWrapperWithApollo({ callouts: ['random'], isLoading: true }); + + expect(findBanner().exists()).toBe(false); + }); + + it('does not show the banner if the user has previously dismissed it', async () => { + wrapper = await createWrapperWithApollo({ callouts: ['pipeline_needs_banner'.toUpperCase()] }); + + expect(findBanner().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js index 6deec06e344..258f2bda829 100644 --- a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js +++ b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js @@ -1,12 +1,12 @@ import { GlAlert } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { CI_CONFIG_STATUS_INVALID, CI_CONFIG_STATUS_VALID } from '~/pipeline_editor/constants'; +import { CI_CONFIG_STATUS_VALID } from '~/pipeline_editor/constants'; import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue'; import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import StagePill from '~/pipelines/components/pipeline_graph/stage_pill.vue'; -import { DRAW_FAILURE, EMPTY_PIPELINE_DATA, INVALID_CI_CONFIG } from '~/pipelines/constants'; +import { DRAW_FAILURE } from '~/pipelines/constants'; import { invalidNeedsData, pipelineData, singleStageData } from './mock_data'; describe('pipeline graph component', () => { @@ -42,31 +42,6 @@ describe('pipeline graph component', () => { wrapper.destroy(); }); - describe('with no data', () => { - beforeEach(() => { - wrapper = createComponent({ pipelineData: {} }); - }); - - it('does not render the graph', () => { - expect(wrapper.text()).toBe(wrapper.vm.$options.errorTexts[EMPTY_PIPELINE_DATA]); - expect(findPipelineGraph().exists()).toBe(false); - expect(findAllStagePills()).toHaveLength(0); - expect(findAllJobPills()).toHaveLength(0); - }); - }); - - describe('with `INVALID` status', () => { - beforeEach(() => { - wrapper = createComponent({ pipelineData: { status: CI_CONFIG_STATUS_INVALID } }); - }); - - it('renders an error message and does not render the graph', () => { - expect(findAlert().exists()).toBe(true); - expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[INVALID_CI_CONFIG]); - expect(findPipelineGraph().exists()).toBe(false); - }); - }); - describe('with `VALID` status', () => { beforeEach(() => { wrapper = createComponent({ diff --git a/spec/graphql/features/authorization_spec.rb b/spec/graphql/features/authorization_spec.rb index d2a6b91d1c2..64e423e2bf8 100644 --- a/spec/graphql/features/authorization_spec.rb +++ b/spec/graphql/features/authorization_spec.rb @@ -376,6 +376,26 @@ RSpec.describe 'DeclarativePolicy authorization in GraphQL ' do end end + describe 'Authorization on GraphQL::Execution::Execute::SKIP' do + let(:type) do + type_factory do |type| + type.authorize permission_single + end + end + + let(:query_type) do + query_factory do |query| + query.field :item, [type], null: true, resolver: new_resolver(GraphQL::Execution::Execute::SKIP) + end + end + + it 'skips redaction' do + expect(Ability).not_to receive(:allowed?) + + result + end + end + private def permit(*permissions) diff --git a/spec/graphql/resolvers/namespace_projects_resolver_spec.rb b/spec/graphql/resolvers/namespace_projects_resolver_spec.rb index 147a02e1d79..618d012bd6d 100644 --- a/spec/graphql/resolvers/namespace_projects_resolver_spec.rb +++ b/spec/graphql/resolvers/namespace_projects_resolver_spec.rb @@ -112,7 +112,7 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do subject(:projects) { resolve_projects(args) } let(:include_subgroups) { false } - let(:project_3) { create(:project, name: 'Project', path: 'project', namespace: namespace) } + let!(:project_3) { create(:project, name: 'Project', path: 'project', namespace: namespace) } context 'when ids is provided' do let(:ids) { [project_3.to_global_id.to_s] } diff --git a/spec/knapsack_env.rb b/spec/knapsack_env.rb index 7dc1a43d644..727d18f32e2 100644 --- a/spec/knapsack_env.rb +++ b/spec/knapsack_env.rb @@ -3,44 +3,9 @@ require 'knapsack' module KnapsackEnv - class RSpecContextAdapter < Knapsack::Adapters::RSpecAdapter - def bind_time_tracker - ::RSpec.configure do |config| - # Original version starts timer in `config.prepend_before(:each) do` - # https://github.com/KnapsackPro/knapsack/blob/v1.17.0/lib/knapsack/adapters/rspec_adapter.rb#L9 - config.prepend_before(:context) do - Knapsack.tracker.start_timer - end - - # Original version is `config.prepend_before(:each) do` - # https://github.com/KnapsackPro/knapsack/blob/v1.17.0/lib/knapsack/adapters/rspec_adapter.rb#L9 - config.prepend_before(:each) do # rubocop:disable RSpec/HookArgument - current_example_group = - if ::RSpec.respond_to?(:current_example) - ::RSpec.current_example.metadata[:example_group] - else - example.metadata - end - - Knapsack.tracker.test_path = Knapsack::Adapters::RSpecAdapter.test_path(current_example_group) - end - - # Original version stops timer in `config.append_after(:each) do` - # https://github.com/KnapsackPro/knapsack/blob/v1.17.0/lib/knapsack/adapters/rspec_adapter.rb#L20 - config.append_after(:context) do - Knapsack.tracker.stop_timer - end - - config.after(:suite) do - Knapsack.logger.info(Knapsack::Presenter.global_time) - end - end - end - end - def self.configure! return unless ENV['CI'] && ENV['KNAPSACK_GENERATE_REPORT'] && !ENV['NO_KNAPSACK'] - RSpecContextAdapter.bind + Knapsack::Adapters::RSpecAdapter.bind end end diff --git a/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb b/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb index 74834fb9014..43cbe71dd6b 100644 --- a/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb +++ b/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb @@ -214,7 +214,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI do expect(Gitlab::SidekiqCluster).not_to receive(:start) expect { cli.run(%W(#{flag} unknown_field=chatops)) } - .to raise_error(Gitlab::SidekiqConfig::CliMethods::QueryError) + .to raise_error(Gitlab::SidekiqConfig::WorkerMatcher::QueryError) end end end diff --git a/spec/lib/gitlab/sidekiq_config/cli_methods_spec.rb b/spec/lib/gitlab/sidekiq_config/cli_methods_spec.rb index 01e7c06249a..bc63289a344 100644 --- a/spec/lib/gitlab/sidekiq_config/cli_methods_spec.rb +++ b/spec/lib/gitlab/sidekiq_config/cli_methods_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rspec-parameterized' RSpec.describe Gitlab::SidekiqConfig::CliMethods do let(:dummy_root) { '/tmp/' } @@ -122,10 +121,8 @@ RSpec.describe Gitlab::SidekiqConfig::CliMethods do end end - describe '.query_workers' do - using RSpec::Parameterized::TableSyntax - - let(:queues) do + describe '.query_queues' do + let(:worker_metadatas) do [ { name: 'a', @@ -162,79 +159,16 @@ RSpec.describe Gitlab::SidekiqConfig::CliMethods do ] end - context 'with valid input' do - where(:query, :selected_queues) do - # feature_category - 'feature_category=category_a' | %w(a a:2) - 'feature_category=category_a,category_c' | %w(a a:2 c) - 'feature_category=category_a|feature_category=category_c' | %w(a a:2 c) - 'feature_category!=category_a' | %w(b c) - - # has_external_dependencies - 'has_external_dependencies=true' | %w(b) - 'has_external_dependencies=false' | %w(a a:2 c) - 'has_external_dependencies=true,false' | %w(a a:2 b c) - 'has_external_dependencies=true|has_external_dependencies=false' | %w(a a:2 b c) - 'has_external_dependencies!=true' | %w(a a:2 c) - - # urgency - 'urgency=high' | %w(a:2 b) - 'urgency=low' | %w(a) - 'urgency=high,low,throttled' | %w(a a:2 b c) - 'urgency=low|urgency=throttled' | %w(a c) - 'urgency!=high' | %w(a c) - - # name - 'name=a' | %w(a) - 'name=a,b' | %w(a b) - 'name=a,a:2|name=b' | %w(a a:2 b) - 'name!=a,a:2' | %w(b c) - - # resource_boundary - 'resource_boundary=memory' | %w(b c) - 'resource_boundary=memory,cpu' | %w(a b c) - 'resource_boundary=memory|resource_boundary=cpu' | %w(a b c) - 'resource_boundary!=memory,cpu' | %w(a:2) - - # tags - 'tags=no_disk_io' | %w(a b) - 'tags=no_disk_io,git_access' | %w(a a:2 b) - 'tags=no_disk_io|tags=git_access' | %w(a a:2 b) - 'tags=no_disk_io&tags=git_access' | %w(a) - 'tags!=no_disk_io' | %w(a:2 c) - 'tags!=no_disk_io,git_access' | %w(c) - 'tags=unknown_tag' | [] - 'tags!=no_disk_io' | %w(a:2 c) - 'tags!=no_disk_io,git_access' | %w(c) - 'tags!=unknown_tag' | %w(a a:2 b c) - - # combinations - 'feature_category=category_a&urgency=high' | %w(a:2) - 'feature_category=category_a&urgency=high|feature_category=category_c' | %w(a:2 c) - end + let(:worker_matcher) { double(:WorkerMatcher) } + let(:query) { 'feature_category=category_a,category_c' } - with_them do - it do - expect(described_class.query_workers(query, queues)) - .to match_array(selected_queues) - end - end + before do + allow(::Gitlab::SidekiqConfig::WorkerMatcher).to receive(:new).with(query).and_return(worker_matcher) + allow(worker_matcher).to receive(:match?).and_return(true, true, false, true) end - context 'with invalid input' do - where(:query, :error) do - 'feature_category="category_a"' | described_class::InvalidTerm - 'feature_category=' | described_class::InvalidTerm - 'feature_category~category_a' | described_class::InvalidTerm - 'worker_name=a' | described_class::UnknownPredicate - end - - with_them do - it do - expect { described_class.query_workers(query, queues) } - .to raise_error(error) - end - end + it 'returns the queue names of matched workers' do + expect(described_class.query_queues(query, worker_metadatas)).to match(%w(a a:2 c)) end end end diff --git a/spec/lib/gitlab/sidekiq_config/worker_matcher_spec.rb b/spec/lib/gitlab/sidekiq_config/worker_matcher_spec.rb new file mode 100644 index 00000000000..75e9c8c100b --- /dev/null +++ b/spec/lib/gitlab/sidekiq_config/worker_matcher_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require 'rspec-parameterized' + +RSpec.describe Gitlab::SidekiqConfig::WorkerMatcher do + describe '#match?' do + using RSpec::Parameterized::TableSyntax + + let(:worker_metadatas) do + [ + { + name: 'a', + feature_category: :category_a, + has_external_dependencies: false, + urgency: :low, + resource_boundary: :cpu, + tags: [:no_disk_io, :git_access] + }, + { + name: 'a:2', + feature_category: :category_a, + has_external_dependencies: false, + urgency: :high, + resource_boundary: :none, + tags: [:git_access] + }, + { + name: 'b', + feature_category: :category_b, + has_external_dependencies: true, + urgency: :high, + resource_boundary: :memory, + tags: [:no_disk_io] + }, + { + name: 'c', + feature_category: :category_c, + has_external_dependencies: false, + urgency: :throttled, + resource_boundary: :memory, + tags: [] + } + ] + end + + context 'with valid input' do + where(:query, :expected_metadatas) do + # feature_category + 'feature_category=category_a' | %w(a a:2) + 'feature_category=category_a,category_c' | %w(a a:2 c) + 'feature_category=category_a|feature_category=category_c' | %w(a a:2 c) + 'feature_category!=category_a' | %w(b c) + + # has_external_dependencies + 'has_external_dependencies=true' | %w(b) + 'has_external_dependencies=false' | %w(a a:2 c) + 'has_external_dependencies=true,false' | %w(a a:2 b c) + 'has_external_dependencies=true|has_external_dependencies=false' | %w(a a:2 b c) + 'has_external_dependencies!=true' | %w(a a:2 c) + + # urgency + 'urgency=high' | %w(a:2 b) + 'urgency=low' | %w(a) + 'urgency=high,low,throttled' | %w(a a:2 b c) + 'urgency=low|urgency=throttled' | %w(a c) + 'urgency!=high' | %w(a c) + + # name + 'name=a' | %w(a) + 'name=a,b' | %w(a b) + 'name=a,a:2|name=b' | %w(a a:2 b) + 'name!=a,a:2' | %w(b c) + + # resource_boundary + 'resource_boundary=memory' | %w(b c) + 'resource_boundary=memory,cpu' | %w(a b c) + 'resource_boundary=memory|resource_boundary=cpu' | %w(a b c) + 'resource_boundary!=memory,cpu' | %w(a:2) + + # tags + 'tags=no_disk_io' | %w(a b) + 'tags=no_disk_io,git_access' | %w(a a:2 b) + 'tags=no_disk_io|tags=git_access' | %w(a a:2 b) + 'tags=no_disk_io&tags=git_access' | %w(a) + 'tags!=no_disk_io' | %w(a:2 c) + 'tags!=no_disk_io,git_access' | %w(c) + 'tags=unknown_tag' | [] + 'tags!=no_disk_io' | %w(a:2 c) + 'tags!=no_disk_io,git_access' | %w(c) + 'tags!=unknown_tag' | %w(a a:2 b c) + + # combinations + 'feature_category=category_a&urgency=high' | %w(a:2) + 'feature_category=category_a&urgency=high|feature_category=category_c' | %w(a:2 c) + + # Match all + '*' | %w(a a:2 b c) + end + + with_them do + it do + matched_metadatas = worker_metadatas.select do |metadata| + described_class.new(query).match?(metadata) + end + expect(matched_metadatas.map { |m| m[:name] }).to match_array(expected_metadatas) + end + end + end + + context 'with invalid input' do + where(:query, :error) do + 'feature_category="category_a"' | described_class::InvalidTerm + 'feature_category=' | described_class::InvalidTerm + 'feature_category~category_a' | described_class::InvalidTerm + 'worker_name=a' | described_class::UnknownPredicate + end + + with_them do + it do + worker_metadatas.each do |metadata| + expect { described_class.new(query).match?(metadata) } + .to raise_error(error) + end + end + end + end + end +end diff --git a/spec/models/todo_spec.rb b/spec/models/todo_spec.rb index a7d26c40909..c4146b347d7 100644 --- a/spec/models/todo_spec.rb +++ b/spec/models/todo_spec.rb @@ -376,6 +376,22 @@ RSpec.describe Todo do end end + describe '.group_by_user_id_and_state' do + let_it_be(:user1) { create(:user) } + let_it_be(:user2) { create(:user) } + + before do + create(:todo, user: user1, state: :pending) + create(:todo, user: user1, state: :pending) + create(:todo, user: user1, state: :done) + create(:todo, user: user2, state: :pending) + end + + specify do + expect(Todo.count_grouped_by_user_id_and_state).to eq({ [user1.id, "done"] => 1, [user1.id, "pending"] => 2, [user2.id, "pending"] => 1 }) + end + end + describe '.any_for_target?' do it 'returns true if there are todos for a given target' do todo = create(:todo) diff --git a/spec/requests/api/nuget_group_packages_spec.rb b/spec/requests/api/nuget_group_packages_spec.rb index 1cc12d0af2e..aefbc89dc3b 100644 --- a/spec/requests/api/nuget_group_packages_spec.rb +++ b/spec/requests/api/nuget_group_packages_spec.rb @@ -69,7 +69,7 @@ RSpec.describe API::NugetGroupPackages do let(:take) { 26 } let(:skip) { 0 } let(:include_prereleases) { true } - let(:query_parameters) { { q: search_term, take: take, skip: skip, prerelease: include_prereleases } } + let(:query_parameters) { { q: search_term, take: take, skip: skip, prerelease: include_prereleases }.compact } subject { get api(url), headers: {}} @@ -145,7 +145,7 @@ RSpec.describe API::NugetGroupPackages do let(:take) { 26 } let(:skip) { 0 } let(:include_prereleases) { false } - let(:query_parameters) { { q: search_term, take: take, skip: skip, prerelease: include_prereleases } } + let(:query_parameters) { { q: search_term, take: take, skip: skip, prerelease: include_prereleases }.compact } let(:url) { "/groups/#{group.id}/-/packages/nuget/query?#{query_parameters.to_query}" } it_behaves_like 'returning response status', :forbidden diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb index 227f39abd0b..59f936509df 100644 --- a/spec/services/todo_service_spec.rb +++ b/spec/services/todo_service_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe TodoService do + include AfterNextHelpers + let_it_be(:project) { create(:project, :repository) } let_it_be(:author) { create(:user) } let_it_be(:assignee) { create(:user) } @@ -343,19 +345,19 @@ RSpec.describe TodoService do describe '#destroy_target' do it 'refreshes the todos count cache for users with todos on the target' do - create(:todo, target: issue, user: john_doe, author: john_doe, project: issue.project) + create(:todo, state: :pending, target: issue, user: john_doe, author: john_doe, project: issue.project) - expect_any_instance_of(User).to receive(:update_todos_count_cache).and_call_original + expect_next(Users::UpdateTodoCountCacheService, [john_doe]).to receive(:execute) - service.destroy_target(issue) { } + service.destroy_target(issue) { issue.destroy! } end it 'does not refresh the todos count cache for users with only done todos on the target' do create(:todo, :done, target: issue, user: john_doe, author: john_doe, project: issue.project) - expect_any_instance_of(User).not_to receive(:update_todos_count_cache) + expect(Users::UpdateTodoCountCacheService).not_to receive(:new) - service.destroy_target(issue) { } + service.destroy_target(issue) { issue.destroy! } end it 'yields the target to the caller' do @@ -1099,13 +1101,9 @@ RSpec.describe TodoService do it 'updates cached counts when a todo is created' do issue = create(:issue, project: project, assignees: [john_doe], author: author) - expect(john_doe.todos_pending_count).to eq(0) - expect(john_doe).to receive(:update_todos_count_cache).and_call_original + expect_next(Users::UpdateTodoCountCacheService, [john_doe]).to receive(:execute) service.new_issue(issue, author) - - expect(Todo.where(user_id: john_doe.id, state: :pending).count).to eq 1 - expect(john_doe.todos_pending_count).to eq(1) end shared_examples 'updating todos state' do |state, new_state, new_resolved_by = nil| diff --git a/spec/services/users/update_todo_count_cache_service_spec.rb b/spec/services/users/update_todo_count_cache_service_spec.rb new file mode 100644 index 00000000000..3e3618b1291 --- /dev/null +++ b/spec/services/users/update_todo_count_cache_service_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Users::UpdateTodoCountCacheService do + describe '#execute' do + let_it_be(:user1) { create(:user) } + let_it_be(:user2) { create(:user) } + + let_it_be(:todo1) { create(:todo, user: user1, state: :done) } + let_it_be(:todo2) { create(:todo, user: user1, state: :done) } + let_it_be(:todo3) { create(:todo, user: user1, state: :pending) } + let_it_be(:todo4) { create(:todo, user: user2, state: :done) } + let_it_be(:todo5) { create(:todo, user: user2, state: :pending) } + let_it_be(:todo6) { create(:todo, user: user2, state: :pending) } + + it 'updates the todos_counts for users', :use_clean_rails_memory_store_caching do + Rails.cache.write(['users', user1.id, 'todos_done_count'], 0) + Rails.cache.write(['users', user1.id, 'todos_pending_count'], 0) + Rails.cache.write(['users', user2.id, 'todos_done_count'], 0) + Rails.cache.write(['users', user2.id, 'todos_pending_count'], 0) + + expect { described_class.new([user1, user2]).execute } + .to change(user1, :todos_done_count).from(0).to(2) + .and change(user1, :todos_pending_count).from(0).to(1) + .and change(user2, :todos_done_count).from(0).to(1) + .and change(user2, :todos_pending_count).from(0).to(2) + + Todo.delete_all + + expect { described_class.new([user1, user2]).execute } + .to change(user1, :todos_done_count).from(2).to(0) + .and change(user1, :todos_pending_count).from(1).to(0) + .and change(user2, :todos_done_count).from(1).to(0) + .and change(user2, :todos_pending_count).from(2).to(0) + end + + it 'avoids N+1 queries' do + control_count = ActiveRecord::QueryRecorder.new { described_class.new([user1]).execute }.count + + expect { described_class.new([user1, user2]).execute }.not_to exceed_query_limit(control_count) + end + + it 'executes one query per batch of users' do + stub_const("#{described_class}::QUERY_BATCH_SIZE", 1) + + expect(ActiveRecord::QueryRecorder.new { described_class.new([user1]).execute }.count).to eq(1) + expect(ActiveRecord::QueryRecorder.new { described_class.new([user1, user2]).execute }.count).to eq(2) + end + + it 'sets the cache expire time to the users count_cache_validity_period' do + allow(user1).to receive(:count_cache_validity_period).and_return(1.minute) + allow(user2).to receive(:count_cache_validity_period).and_return(1.hour) + + expect(Rails.cache).to receive(:write).with(['users', user1.id, anything], anything, expires_in: 1.minute).twice + expect(Rails.cache).to receive(:write).with(['users', user2.id, anything], anything, expires_in: 1.hour).twice + + described_class.new([user1, user2]).execute + end + end +end diff --git a/spec/support/shared_examples/requests/api/nuget_endpoints_shared_examples.rb b/spec/support/shared_examples/requests/api/nuget_endpoints_shared_examples.rb index cdb9af31dca..db70bc75c63 100644 --- a/spec/support/shared_examples/requests/api/nuget_endpoints_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/nuget_endpoints_shared_examples.rb @@ -225,7 +225,7 @@ RSpec.shared_examples 'handling nuget search requests' do |anonymous_requests_ex let(:take) { 26 } let(:skip) { 0 } let(:include_prereleases) { true } - let(:query_parameters) { { q: search_term, take: take, skip: skip, prerelease: include_prereleases } } + let(:query_parameters) { { q: search_term, take: take, skip: skip, prerelease: include_prereleases }.compact } subject { get api(url) } diff --git a/workhorse/internal/upload/rewrite.go b/workhorse/internal/upload/rewrite.go index 29c3e54f4f2..85063d65c1b 100644 --- a/workhorse/internal/upload/rewrite.go +++ b/workhorse/internal/upload/rewrite.go @@ -192,7 +192,10 @@ func handleExifUpload(ctx context.Context, r io.Reader, filename string, imageTy return nil, err } - tmpfile.Seek(0, io.SeekStart) + if _, err := tmpfile.Seek(0, io.SeekStart); err != nil { + return nil, err + } + isValidType := false switch imageType { case exif.TypeJPEG: @@ -201,7 +204,10 @@ func handleExifUpload(ctx context.Context, r io.Reader, filename string, imageTy isValidType = isTIFF(tmpfile) } - tmpfile.Seek(0, io.SeekStart) + if _, err := tmpfile.Seek(0, io.SeekStart); err != nil { + return nil, err + } + if !isValidType { log.WithContextFields(ctx, log.Fields{ "filename": filename, |