Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.rubocop.yml2
-rw-r--r--.rubocop_manual_todo.yml14
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock4
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/app.vue7
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/charts_config.js61
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/projects_and_groups_chart.vue224
-rw-r--r--app/assets/javascripts/analytics/usage_trends/graphql/queries/groups.query.graphql13
-rw-r--r--app/assets/javascripts/analytics/usage_trends/graphql/queries/projects.query.graphql13
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue71
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue9
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue136
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue53
-rw-r--r--app/assets/javascripts/repository/components/upload_blob_modal.vue218
-rw-r--r--app/assets/javascripts/repository/index.js15
-rw-r--r--app/assets/stylesheets/pages/note_form.scss26
-rw-r--r--app/experiments/application_experiment.rb66
-rw-r--r--app/graphql/mutations/merge_requests/set_wip.rb2
-rw-r--r--app/graphql/resolvers/concerns/resolves_snippets.rb2
-rw-r--r--app/graphql/types/merge_request_type.rb4
-rw-r--r--app/graphql/types/query_type.rb2
-rw-r--r--app/graphql/types/todo_state_enum.rb4
-rw-r--r--app/helpers/tree_helper.rb2
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml2
-rw-r--r--app/views/projects/_files.html.haml1
-rw-r--r--changelogs/unreleased/254280-replace-bootstrap-modal-trigger-in-app-assets-javascripts-blob-blo.yml5
-rw-r--r--changelogs/unreleased/275818-be-add-info-error-messages-to-security-widget-summary-diverged.yml6
-rw-r--r--changelogs/unreleased/290288-composer-cache-build-pages-task.yml5
-rw-r--r--changelogs/unreleased/322019-iterations-list-and-report-views-missing-left-sidebar-status.yml5
-rw-r--r--changelogs/unreleased/epic_count_query.yml6
-rw-r--r--changelogs/unreleased/remove-bootstrap-dropdowns-from-note-components.yml6
-rw-r--r--config/initializers/gitlab_experiment.rb4
-rw-r--r--danger/product_intelligence/Dangerfile8
-rw-r--r--db/migrate/20210219111040_add_epic_issue_composite_index.rb18
-rw-r--r--db/schema_migrations/202102191110401
-rw-r--r--db/structure.sql2
-rw-r--r--doc/api/graphql/reference/index.md149
-rw-r--r--doc/development/fe_guide/editor_lite.md220
-rw-r--r--lib/gitlab/graphql/docs/helper.rb2
-rw-r--r--lib/rspec_flaky/config.rb21
-rw-r--r--lib/tasks/gitlab/packages/composer.rake20
-rw-r--r--locale/gitlab.pot27
-rw-r--r--qa/qa/page/component/note.rb3
-rw-r--r--qa/qa/resource/snippet.rb27
-rw-r--r--qa/qa/runtime/api/repository_storage_moves.rb22
-rw-r--r--qa/qa/specs/features/api/3_create/snippet/snippet_repository_storage_move_spec.rb45
-rw-r--r--qa/qa/support/api.rb1
-rwxr-xr-xscripts/flaky_examples/prune-old-flaky-examples9
-rw-r--r--spec/experiments/application_experiment/cache_spec.rb54
-rw-r--r--spec/experiments/application_experiment_spec.rb31
-rw-r--r--spec/features/discussion_comments/commit_spec.rb2
-rw-r--r--spec/features/discussion_comments/issue_spec.rb2
-rw-r--r--spec/features/discussion_comments/merge_request_spec.rb2
-rw-r--r--spec/features/discussion_comments/snippets_spec.rb4
-rw-r--r--spec/features/merge_request/user_posts_notes_spec.rb5
-rw-r--r--spec/features/projects/files/user_uploads_files_spec.rb29
-rw-r--r--spec/features/projects/show/user_uploads_files_spec.rb4
-rw-r--r--spec/frontend/analytics/usage_trends/components/app_spec.js7
-rw-r--r--spec/frontend/analytics/usage_trends/components/projects_and_groups_chart_spec.js215
-rw-r--r--spec/frontend/notes/components/comment_form_spec.js10
-rw-r--r--spec/frontend/notes/components/notes_app_spec.js4
-rw-r--r--spec/frontend/repository/components/breadcrumbs_spec.js84
-rw-r--r--spec/frontend/repository/components/upload_blob_modal_spec.js193
-rw-r--r--spec/graphql/types/merge_request_type_spec.rb31
-rw-r--r--spec/spec_helper.rb2
-rw-r--r--spec/support/gitlab_experiment.rb8
-rw-r--r--spec/support/shared_examples/features/discussion_comments_shared_example.rb216
-rw-r--r--spec/support/shared_examples/features/project_upload_files_shared_examples.rb35
-rw-r--r--spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb2
-rw-r--r--spec/tasks/gitlab/packages/composer_rake_spec.rb28
-rw-r--r--spec/tooling/rspec_flaky/config_spec.rb (renamed from spec/lib/rspec_flaky/config_spec.rb)26
-rw-r--r--spec/tooling/rspec_flaky/example_spec.rb (renamed from spec/lib/rspec_flaky/example_spec.rb)2
-rw-r--r--spec/tooling/rspec_flaky/flaky_example_spec.rb (renamed from spec/lib/rspec_flaky/flaky_example_spec.rb)40
-rw-r--r--spec/tooling/rspec_flaky/flaky_examples_collection_spec.rb (renamed from spec/lib/rspec_flaky/flaky_examples_collection_spec.rb)2
-rw-r--r--spec/tooling/rspec_flaky/listener_spec.rb (renamed from spec/lib/rspec_flaky/listener_spec.rb)30
-rw-r--r--spec/tooling/rspec_flaky/report_spec.rb (renamed from spec/lib/rspec_flaky/report_spec.rb)16
-rw-r--r--tooling/rspec_flaky/config.rb27
-rw-r--r--tooling/rspec_flaky/example.rb (renamed from lib/rspec_flaky/example.rb)11
-rw-r--r--tooling/rspec_flaky/flaky_example.rb (renamed from lib/rspec_flaky/flaky_example.rb)2
-rw-r--r--tooling/rspec_flaky/flaky_examples_collection.rb (renamed from lib/rspec_flaky/flaky_examples_collection.rb)0
-rw-r--r--tooling/rspec_flaky/listener.rb (renamed from lib/rspec_flaky/listener.rb)22
-rw-r--r--tooling/rspec_flaky/report.rb (renamed from lib/rspec_flaky/report.rb)6
82 files changed, 1627 insertions, 1090 deletions
diff --git a/.rubocop.yml b/.rubocop.yml
index b9f52ebc055..56d9f784113 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -243,7 +243,7 @@ Gitlab/Json:
- 'db/**/*'
- 'qa/**/*'
- 'scripts/**/*'
- - 'lib/rspec_flaky/**/*'
+ - 'tooling/rspec_flaky/**/*'
- 'lib/quality/**/*'
- 'tooling/danger/**/*'
diff --git a/.rubocop_manual_todo.yml b/.rubocop_manual_todo.yml
index b8351966140..02962c0f798 100644
--- a/.rubocop_manual_todo.yml
+++ b/.rubocop_manual_todo.yml
@@ -485,8 +485,8 @@ Rails/TimeZone:
- 'lib/json_web_token/token.rb'
- 'lib/object_storage/direct_upload.rb'
- 'lib/quality/seeders/issues.rb'
- - 'lib/rspec_flaky/flaky_example.rb'
- - 'lib/rspec_flaky/report.rb'
+ - 'tooling/rspec_flaky/flaky_example.rb'
+ - 'tooling/rspec_flaky/report.rb'
- 'lib/tasks/gitlab/assets.rake'
- 'lib/tasks/gitlab/backup.rake'
- 'lib/tasks/gitlab/cleanup.rake'
@@ -552,9 +552,9 @@ Rails/TimeZone:
- 'spec/lib/gitlab/x509/signature_spec.rb'
- 'spec/lib/grafana/time_window_spec.rb'
- 'spec/lib/json_web_token/hmac_token_spec.rb'
- - 'spec/lib/rspec_flaky/flaky_example_spec.rb'
- - 'spec/lib/rspec_flaky/listener_spec.rb'
- - 'spec/lib/rspec_flaky/report_spec.rb'
+ - 'spec/tooling/rspec_flaky/flaky_example_spec.rb'
+ - 'spec/tooling/rspec_flaky/listener_spec.rb'
+ - 'spec/tooling/rspec_flaky/report_spec.rb'
RSpec/TimecopFreeze:
Exclude:
@@ -627,8 +627,8 @@ RSpec/TimecopFreeze:
- 'spec/lib/gitlab/puma_logging/json_formatter_spec.rb'
- 'spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb'
- 'spec/lib/json_web_token/hmac_token_spec.rb'
- - 'spec/lib/rspec_flaky/flaky_example_spec.rb'
- - 'spec/lib/rspec_flaky/listener_spec.rb'
+ - 'spec/tooling/rspec_flaky/flaky_example_spec.rb'
+ - 'spec/tooling/rspec_flaky/listener_spec.rb'
- 'spec/models/active_session_spec.rb'
- 'spec/serializers/entity_date_helper_spec.rb'
- 'spec/support/cycle_analytics_helpers/test_generation.rb'
diff --git a/Gemfile b/Gemfile
index 755a388b71b..773815d6b40 100644
--- a/Gemfile
+++ b/Gemfile
@@ -479,7 +479,7 @@ gem 'flipper', '~> 0.17.1'
gem 'flipper-active_record', '~> 0.17.1'
gem 'flipper-active_support_cache_store', '~> 0.17.1'
gem 'unleash', '~> 0.1.5'
-gem 'gitlab-experiment', '~> 0.4.9'
+gem 'gitlab-experiment', '~> 0.4.12'
# Structured logging
gem 'lograge', '~> 0.5'
diff --git a/Gemfile.lock b/Gemfile.lock
index a8f9fea0a59..333c2a0fef5 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -425,7 +425,7 @@ GEM
github-markup (1.7.0)
gitlab-chronic (0.10.5)
numerizer (~> 0.2)
- gitlab-experiment (0.4.9)
+ gitlab-experiment (0.4.12)
activesupport (>= 3.0)
scientist (~> 1.5, >= 1.5.0)
gitlab-fog-azure-rm (1.0.0)
@@ -1371,7 +1371,7 @@ DEPENDENCIES
gitaly (~> 13.9.0.pre.rc1)
github-markup (~> 1.7.0)
gitlab-chronic (~> 0.10.5)
- gitlab-experiment (~> 0.4.9)
+ gitlab-experiment (~> 0.4.12)
gitlab-fog-azure-rm (~> 1.0)
gitlab-labkit (= 0.14.0)
gitlab-license (~> 1.3)
diff --git a/app/assets/javascripts/analytics/usage_trends/components/app.vue b/app/assets/javascripts/analytics/usage_trends/components/app.vue
index c6436160ea2..4c5ddd7f458 100644
--- a/app/assets/javascripts/analytics/usage_trends/components/app.vue
+++ b/app/assets/javascripts/analytics/usage_trends/components/app.vue
@@ -1,7 +1,6 @@
<script>
import { TODAY, TOTAL_DAYS_TO_SHOW, START_DATE } from '../constants';
import ChartsConfig from './charts_config';
-import ProjectsAndGroupsChart from './projects_and_groups_chart.vue';
import UsageCounts from './usage_counts.vue';
import UsageTrendsCountChart from './usage_trends_count_chart.vue';
import UsersChart from './users_chart.vue';
@@ -12,7 +11,6 @@ export default {
UsageCounts,
UsageTrendsCountChart,
UsersChart,
- ProjectsAndGroupsChart,
},
TOTAL_DAYS_TO_SHOW,
START_DATE,
@@ -29,11 +27,6 @@ export default {
:end-date="$options.TODAY"
:total-data-points="$options.TOTAL_DAYS_TO_SHOW"
/>
- <projects-and-groups-chart
- :start-date="$options.START_DATE"
- :end-date="$options.TODAY"
- :total-data-points="$options.TOTAL_DAYS_TO_SHOW"
- />
<usage-trends-count-chart
v-for="chartOptions in $options.configs"
:key="chartOptions.chartTitle"
diff --git a/app/assets/javascripts/analytics/usage_trends/components/charts_config.js b/app/assets/javascripts/analytics/usage_trends/components/charts_config.js
index b6b440b710f..014f823cdc4 100644
--- a/app/assets/javascripts/analytics/usage_trends/components/charts_config.js
+++ b/app/assets/javascripts/analytics/usage_trends/components/charts_config.js
@@ -1,12 +1,35 @@
-import { s__, __, sprintf } from '~/locale';
+import { s__, __ } from '~/locale';
import query from '../graphql/queries/usage_count.query.graphql';
const noDataMessage = s__('UsageTrends|No data available.');
export default [
{
- loadChartError: sprintf(
- s__('UsageTrends|Could not load the pipelines chart. Please refresh the page to try again.'),
+ loadChartError: s__(
+ 'UsageTrends|Could not load the projects and groups chart. Please refresh the page to try again.',
+ ),
+ noDataMessage,
+ chartTitle: s__('UsageTrends|Total projects & groups'),
+ yAxisTitle: s__('UsageTrends|Total projects & groups'),
+ xAxisTitle: s__('UsageTrends|Month'),
+ queries: [
+ {
+ query,
+ title: s__('UsageTrends|Total projects'),
+ identifier: 'PROJECTS',
+ loadError: s__('UsageTrends|There was an error fetching the projects. Please try again.'),
+ },
+ {
+ query,
+ title: s__('UsageTrends|Total groups'),
+ identifier: 'GROUPS',
+ loadError: s__('UsageTrends|There was an error fetching the groups. Please try again.'),
+ },
+ ],
+ },
+ {
+ loadChartError: s__(
+ 'UsageTrends|Could not load the pipelines chart. Please refresh the page to try again.',
),
noDataMessage,
chartTitle: s__('UsageTrends|Pipelines'),
@@ -17,39 +40,47 @@ export default [
query,
title: s__('UsageTrends|Pipelines total'),
identifier: 'PIPELINES',
- loadError: sprintf(s__('UsageTrends|There was an error fetching the total pipelines')),
+ loadError: s__(
+ 'UsageTrends|There was an error fetching the total pipelines. Please try again.',
+ ),
},
{
query,
title: s__('UsageTrends|Pipelines succeeded'),
identifier: 'PIPELINES_SUCCEEDED',
- loadError: sprintf(s__('UsageTrends|There was an error fetching the successful pipelines')),
+ loadError: s__(
+ 'UsageTrends|There was an error fetching the successful pipelines. Please try again.',
+ ),
},
{
query,
title: s__('UsageTrends|Pipelines failed'),
identifier: 'PIPELINES_FAILED',
- loadError: sprintf(s__('UsageTrends|There was an error fetching the failed pipelines')),
+ loadError: s__(
+ 'UsageTrends|There was an error fetching the failed pipelines. Please try again.',
+ ),
},
{
query,
title: s__('UsageTrends|Pipelines canceled'),
identifier: 'PIPELINES_CANCELED',
- loadError: sprintf(s__('UsageTrends|There was an error fetching the cancelled pipelines')),
+ loadError: s__(
+ 'UsageTrends|There was an error fetching the cancelled pipelines. Please try again.',
+ ),
},
{
query,
title: s__('UsageTrends|Pipelines skipped'),
identifier: 'PIPELINES_SKIPPED',
- loadError: sprintf(s__('UsageTrends|There was an error fetching the skipped pipelines')),
+ loadError: s__(
+ 'UsageTrends|There was an error fetching the skipped pipelines. Please try again.',
+ ),
},
],
},
{
- loadChartError: sprintf(
- s__(
- 'UsageTrends|Could not load the issues and merge requests chart. Please refresh the page to try again.',
- ),
+ loadChartError: s__(
+ 'UsageTrends|Could not load the issues and merge requests chart. Please refresh the page to try again.',
),
noDataMessage,
chartTitle: s__('UsageTrends|Issues & Merge Requests'),
@@ -60,13 +91,15 @@ export default [
query,
title: __('Issues'),
identifier: 'ISSUES',
- loadError: sprintf(s__('UsageTrends|There was an error fetching the issues')),
+ loadError: s__('UsageTrends|There was an error fetching the issues. Please try again.'),
},
{
query,
title: __('Merge requests'),
identifier: 'MERGE_REQUESTS',
- loadError: sprintf(s__('UsageTrends|There was an error fetching the merge requests')),
+ loadError: s__(
+ 'UsageTrends|There was an error fetching the merge requests. Please try again.',
+ ),
},
],
},
diff --git a/app/assets/javascripts/analytics/usage_trends/components/projects_and_groups_chart.vue b/app/assets/javascripts/analytics/usage_trends/components/projects_and_groups_chart.vue
deleted file mode 100644
index 66aa939938e..00000000000
--- a/app/assets/javascripts/analytics/usage_trends/components/projects_and_groups_chart.vue
+++ /dev/null
@@ -1,224 +0,0 @@
-<script>
-import { GlAlert } from '@gitlab/ui';
-import { GlLineChart } from '@gitlab/ui/dist/charts';
-import * as Sentry from '@sentry/browser';
-import produce from 'immer';
-import { sortBy } from 'lodash';
-import { formatDateAsMonth } from '~/lib/utils/datetime_utility';
-import { s__, __ } from '~/locale';
-import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
-import latestGroupsQuery from '../graphql/queries/groups.query.graphql';
-import latestProjectsQuery from '../graphql/queries/projects.query.graphql';
-import { getAverageByMonth } from '../utils';
-
-const sortByDate = (data) => sortBy(data, (item) => new Date(item[0]).getTime());
-
-const averageAndSortData = (data = [], maxDataPoints) => {
- const averaged = getAverageByMonth(
- data.length > maxDataPoints ? data.slice(0, maxDataPoints) : data,
- { shouldRound: true },
- );
- return sortByDate(averaged);
-};
-
-export default {
- name: 'ProjectsAndGroupsChart',
- components: { GlAlert, GlLineChart, ChartSkeletonLoader },
- props: {
- startDate: {
- type: Date,
- required: true,
- },
- endDate: {
- type: Date,
- required: true,
- },
- totalDataPoints: {
- type: Number,
- required: true,
- },
- },
- data() {
- return {
- loadingError: false,
- errorMessage: '',
- groups: [],
- projects: [],
- groupsPageInfo: null,
- projectsPageInfo: null,
- };
- },
- apollo: {
- groups: {
- query: latestGroupsQuery,
- variables() {
- return {
- first: this.totalDataPoints,
- after: null,
- };
- },
- update(data) {
- return data.groups?.nodes || [];
- },
- result({ data }) {
- const {
- groups: { pageInfo },
- } = data;
- this.groupsPageInfo = pageInfo;
- this.fetchNextPage({
- query: this.$apollo.queries.groups,
- pageInfo: this.groupsPageInfo,
- dataKey: 'groups',
- errorMessage: this.$options.i18n.loadGroupsDataError,
- });
- },
- error(error) {
- this.handleError({
- message: this.$options.i18n.loadGroupsDataError,
- error,
- dataKey: 'groups',
- });
- },
- },
- projects: {
- query: latestProjectsQuery,
- variables() {
- return {
- first: this.totalDataPoints,
- after: null,
- };
- },
- update(data) {
- return data.projects?.nodes || [];
- },
- result({ data }) {
- const {
- projects: { pageInfo },
- } = data;
- this.projectsPageInfo = pageInfo;
- this.fetchNextPage({
- query: this.$apollo.queries.projects,
- pageInfo: this.projectsPageInfo,
- dataKey: 'projects',
- errorMessage: this.$options.i18n.loadProjectsDataError,
- });
- },
- error(error) {
- this.handleError({
- message: this.$options.i18n.loadProjectsDataError,
- error,
- dataKey: 'projects',
- });
- },
- },
- },
- i18n: {
- yAxisTitle: s__('UsageTrends|Total projects & groups'),
- xAxisTitle: __('Month'),
- loadChartError: s__(
- 'UsageTrends|Could not load the projects and groups chart. Please refresh the page to try again.',
- ),
- loadProjectsDataError: s__('UsageTrends|There was an error while loading the projects'),
- loadGroupsDataError: s__('UsageTrends|There was an error while loading the groups'),
- noDataMessage: s__('UsageTrends|No data available.'),
- },
- computed: {
- isLoadingGroups() {
- return this.$apollo.queries.groups.loading || this.groupsPageInfo?.hasNextPage;
- },
- isLoadingProjects() {
- return this.$apollo.queries.projects.loading || this.projectsPageInfo?.hasNextPage;
- },
- isLoading() {
- return this.isLoadingProjects && this.isLoadingGroups;
- },
- groupChartData() {
- return averageAndSortData(this.groups, this.totalDataPoints);
- },
- projectChartData() {
- return averageAndSortData(this.projects, this.totalDataPoints);
- },
- hasNoData() {
- const { projectChartData, groupChartData } = this;
- return Boolean(!projectChartData.length && !groupChartData.length);
- },
- options() {
- return {
- xAxis: {
- name: this.$options.i18n.xAxisTitle,
- type: 'category',
- axisLabel: {
- formatter: (value) => {
- return formatDateAsMonth(value);
- },
- },
- },
- yAxis: {
- name: this.$options.i18n.yAxisTitle,
- },
- };
- },
- chartData() {
- return [
- {
- name: s__('UsageTrends|Total projects'),
- data: this.projectChartData,
- },
- {
- name: s__('UsageTrends|Total groups'),
- data: this.groupChartData,
- },
- ];
- },
- },
- methods: {
- handleError({ error, message = this.$options.i18n.loadChartError, dataKey = null }) {
- this.loadingError = true;
- this.errorMessage = message;
- if (!dataKey) {
- this.projects = [];
- this.groups = [];
- } else {
- this[dataKey] = [];
- }
- Sentry.captureException(error);
- },
- fetchNextPage({ pageInfo, query, dataKey, errorMessage }) {
- if (pageInfo?.hasNextPage) {
- query
- .fetchMore({
- variables: { first: this.totalDataPoints, after: pageInfo.endCursor },
- updateQuery: (previousResult, { fetchMoreResult }) => {
- const results = produce(fetchMoreResult, (newData) => {
- // eslint-disable-next-line no-param-reassign
- newData[dataKey].nodes = [
- ...previousResult[dataKey].nodes,
- ...newData[dataKey].nodes,
- ];
- });
- return results;
- },
- })
- .catch((error) => {
- this.handleError({ error, message: errorMessage, dataKey });
- });
- }
- },
- },
-};
-</script>
-<template>
- <div>
- <h3>{{ $options.i18n.yAxisTitle }}</h3>
- <chart-skeleton-loader v-if="isLoading" />
- <gl-alert v-else-if="hasNoData" variant="info" :dismissible="false" class="gl-mt-3">
- {{ $options.i18n.noDataMessage }}
- </gl-alert>
- <div v-else>
- <gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3">{{
- errorMessage
- }}</gl-alert>
- <gl-line-chart :option="options" :include-legend-avg-max="true" :data="chartData" />
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/queries/groups.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/groups.query.graphql
deleted file mode 100644
index b1249cc9480..00000000000
--- a/app/assets/javascripts/analytics/usage_trends/graphql/queries/groups.query.graphql
+++ /dev/null
@@ -1,13 +0,0 @@
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
-#import "../fragments/count.fragment.graphql"
-
-query getGroupsCount($first: Int, $after: String) {
- groups: usageTrendsMeasurements(identifier: GROUPS, first: $first, after: $after) {
- nodes {
- ...Count
- }
- pageInfo {
- ...PageInfo
- }
- }
-}
diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/queries/projects.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/projects.query.graphql
deleted file mode 100644
index 2e10b6cce3e..00000000000
--- a/app/assets/javascripts/analytics/usage_trends/graphql/queries/projects.query.graphql
+++ /dev/null
@@ -1,13 +0,0 @@
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
-#import "../fragments/count.fragment.graphql"
-
-query getProjectsCount($first: Int, $after: String) {
- projects: usageTrendsMeasurements(identifier: PROJECTS, first: $first, after: $after) {
- nodes {
- ...Count
- }
- pageInfo {
- ...PageInfo
- }
- }
-}
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index f65f00bcccc..8ede084d912 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -161,42 +161,49 @@ export default {
currentMutation() {
return this.board.id ? updateBoardMutation : createBoardMutation;
},
- mutationVariables() {
+ baseMutationVariables() {
const { board } = this;
- /* eslint-disable @gitlab/require-i18n-strings */
- let baseMutationVariables = {
+ const variables = {
name: board.name,
hideBacklogList: board.hide_backlog_list,
hideClosedList: board.hide_closed_list,
};
- if (this.scopedIssueBoardFeatureEnabled) {
- baseMutationVariables = {
- ...baseMutationVariables,
- weight: board.weight,
- assigneeId: board.assignee?.id ? convertToGraphQLId('User', board.assignee.id) : null,
- milestoneId:
- board.milestone?.id || board.milestone?.id === 0
- ? convertToGraphQLId('Milestone', board.milestone.id)
- : null,
- labelIds: board.labels.map(fullLabelId),
- iterationId: board.iteration_id
- ? convertToGraphQLId('Iteration', board.iteration_id)
- : null,
- };
- }
- /* eslint-enable @gitlab/require-i18n-strings */
return board.id
? {
- ...baseMutationVariables,
+ ...variables,
id: fullBoardId(board.id),
}
: {
- ...baseMutationVariables,
- projectPath: this.projectId ? this.fullPath : null,
- groupPath: this.groupId ? this.fullPath : null,
+ ...variables,
+ projectPath: this.projectId ? this.fullPath : undefined,
+ groupPath: this.groupId ? this.fullPath : undefined,
};
},
+ boardScopeMutationVariables() {
+ /* eslint-disable @gitlab/require-i18n-strings */
+ return {
+ weight: this.board.weight,
+ assigneeId: this.board.assignee?.id
+ ? convertToGraphQLId('User', this.board.assignee.id)
+ : null,
+ milestoneId:
+ this.board.milestone?.id || this.board.milestone?.id === 0
+ ? convertToGraphQLId('Milestone', this.board.milestone.id)
+ : null,
+ labelIds: this.board.labels.map(fullLabelId),
+ iterationId: this.board.iteration_id
+ ? convertToGraphQLId('Iteration', this.board.iteration_id)
+ : null,
+ };
+ /* eslint-enable @gitlab/require-i18n-strings */
+ },
+ mutationVariables() {
+ return {
+ ...this.baseMutationVariables,
+ ...(this.scopedIssueBoardFeatureEnabled ? this.boardScopeMutationVariables : {}),
+ };
+ },
},
mounted() {
this.resetFormState();
@@ -208,6 +215,16 @@ export default {
setIteration(iterationId) {
this.board.iteration_id = iterationId;
},
+ boardCreateResponse(data) {
+ return data.createBoard.board.webPath;
+ },
+ boardUpdateResponse(data) {
+ const path = data.updateBoard.board.webPath;
+ const param = getParameterByName('group_by')
+ ? `?group_by=${getParameterByName('group_by')}`
+ : '';
+ return `${path}${param}`;
+ },
async createOrUpdateBoard() {
const response = await this.$apollo.mutate({
mutation: this.currentMutation,
@@ -215,14 +232,10 @@ export default {
});
if (!this.board.id) {
- return response.data.createBoard.board.webPath;
+ return this.boardCreateResponse(response.data);
}
- const path = response.data.updateBoard.board.webPath;
- const param = getParameterByName('group_by')
- ? `?group_by=${getParameterByName('group_by')}`
- : '';
- return `${path}${param}`;
+ return this.boardUpdateResponse(response.data);
},
async submit() {
if (this.board.name.length === 0) return;
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index 3bd94631396..3ffd28665f9 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -10,6 +10,8 @@ import {
} from '@gitlab/ui';
import { throttle } from 'lodash';
+import BoardForm from 'ee_else_ce/boards/components/board_form.vue';
+
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import axios from '~/lib/utils/axios_utils';
import httpStatusCodes from '~/lib/utils/http_status';
@@ -18,8 +20,6 @@ import eventHub from '../eventhub';
import groupQuery from '../graphql/group_boards.query.graphql';
import projectQuery from '../graphql/project_boards.query.graphql';
-import BoardForm from './board_form.vue';
-
const MIN_BOARDS_TO_VIEW_RECENT = 10;
export default {
@@ -123,6 +123,9 @@ export default {
board() {
return this.currentBoard;
},
+ showCreate() {
+ return this.multipleIssueBoardsAvailable;
+ },
showDelete() {
return this.boards.length > 1;
},
@@ -327,7 +330,7 @@ export default {
<gl-dropdown-divider />
<gl-dropdown-item
- v-if="multipleIssueBoardsAvailable"
+ v-if="showCreate"
v-gl-modal-directive="'board-config-modal'"
data-qa-selector="create_new_board_button"
@click.prevent="showPage('new')"
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 50db3b86025..2866b1a8362 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -1,5 +1,13 @@
<script>
-import { GlButton, GlIcon, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui';
+import {
+ GlButton,
+ GlIcon,
+ GlFormCheckbox,
+ GlTooltipDirective,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+} from '@gitlab/ui';
import Autosize from 'autosize';
import $ from 'jquery';
import { mapActions, mapGetters, mapState } from 'vuex';
@@ -25,6 +33,15 @@ import noteSignedOutWidget from './note_signed_out_widget.vue';
export default {
name: 'CommentForm',
+ i18n: {
+ submitButton: {
+ startThread: __('Start thread'),
+ comment: __('Comment'),
+ commentHelp: __('Add a general comment to this %{noteableDisplayName}.'),
+ },
+ },
+ noteTypeComment: constants.COMMENT,
+ noteTypeDiscussion: constants.DISCUSSION,
components: {
noteSignedOutWidget,
discussionLockedWidget,
@@ -34,6 +51,9 @@ export default {
GlIcon,
CommentFieldLayout,
GlFormCheckbox,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -63,6 +83,12 @@ export default {
'openState',
]),
...mapState(['isToggleStateButtonLoading']),
+ isNoteTypeComment() {
+ return this.noteType === constants.COMMENT;
+ },
+ isNoteTypeDiscussion() {
+ return this.noteType === constants.DISCUSSION;
+ },
noteableDisplayName() {
return splitCamelCase(this.noteableType).toLowerCase();
},
@@ -77,6 +103,11 @@ export default {
? __('Discuss a specific suggestion or question that needs to be resolved.')
: __('Discuss a specific suggestion or question.');
},
+ commentDescription() {
+ return sprintf(this.$options.i18n.submitButton.commentHelp, {
+ noteableDisplayName: this.noteableDisplayName,
+ });
+ },
isOpen() {
return this.openState === constants.OPENED || this.openState === constants.REOPENED;
},
@@ -260,6 +291,12 @@ export default {
setNoteType(type) {
this.noteType = type;
},
+ setNoteTypeToComment() {
+ this.setNoteType(constants.COMMENT);
+ },
+ setNoteTypeToDiscussion() {
+ this.setNoteType(constants.DISCUSSION);
+ },
editCurrentUserLastNote() {
if (this.note === '') {
const lastNote = this.getCurrentUserLastNote;
@@ -354,73 +391,40 @@ export default {
class="gl-text-gray-500"
/>
</gl-form-checkbox>
- <div
- class="btn-group gl-mr-3 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
+ <gl-dropdown
+ split
+ :text="commentButtonTitle"
+ class="gl-mr-3 js-comment-button js-comment-submit-button comment-type-dropdown"
+ category="primary"
+ variant="success"
+ :disabled="disableSubmitButton"
+ data-testid="comment-button"
+ data-qa-selector="comment_button"
+ :data-track-label="trackingLabel"
+ data-track-event="click_button"
+ @click="handleSave()"
>
- <gl-button
- :disabled="disableSubmitButton"
- class="js-comment-button js-comment-submit-button"
- data-qa-selector="comment_button"
- data-testid="comment-button"
- type="submit"
- category="primary"
- variant="success"
- :data-track-label="trackingLabel"
- data-track-event="click_button"
- @click.prevent="handleSave()"
- >{{ commentButtonTitle }}</gl-button
+ <gl-dropdown-item
+ is-check-item
+ :is-checked="isNoteTypeComment"
+ :selected="isNoteTypeComment"
+ @click="setNoteTypeToComment"
>
- <gl-button
- :disabled="disableSubmitButton"
- name="button"
- category="primary"
- variant="success"
- class="note-type-toggle js-note-new-discussion dropdown-toggle"
- data-qa-selector="note_dropdown"
- data-display="static"
- data-toggle="dropdown"
- icon="chevron-down"
- :aria-label="__('Open comment type dropdown')"
- />
-
- <ul class="note-type-dropdown dropdown-open-top dropdown-menu">
- <li :class="{ 'droplab-item-selected': noteType === 'comment' }">
- <button
- type="button"
- class="btn btn-transparent"
- @click.prevent="setNoteType('comment')"
- >
- <gl-icon name="check" class="icon gl-flex-shrink-0" />
- <div class="description">
- <strong>{{ __('Comment') }}</strong>
- <p>
- {{
- sprintf(__('Add a general comment to this %{noteableDisplayName}.'), {
- noteableDisplayName,
- })
- }}
- </p>
- </div>
- </button>
- </li>
- <li class="divider droplab-item-ignore"></li>
- <li :class="{ 'droplab-item-selected': noteType === 'discussion' }">
- <button
- type="button"
- class="btn btn-transparent"
- data-qa-selector="discussion_menu_item"
- @click.prevent="setNoteType('discussion')"
- >
- <gl-icon name="check" class="icon gl-flex-shrink-0" />
- <div class="description">
- <strong>{{ __('Start thread') }}</strong>
- <p>{{ startDiscussionDescription }}</p>
- </div>
- </button>
- </li>
- </ul>
- </div>
-
+ <strong>{{ $options.i18n.submitButton.comment }}</strong>
+ <p class="gl-m-0">{{ commentDescription }}</p>
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ <gl-dropdown-item
+ is-check-item
+ :is-checked="isNoteTypeDiscussion"
+ :selected="isNoteTypeDiscussion"
+ data-qa-selector="discussion_menu_item"
+ @click="setNoteTypeToDiscussion"
+ >
+ <strong>{{ $options.i18n.submitButton.startThread }}</strong>
+ <p class="gl-m-0">{{ startDiscussionDescription }}</p>
+ </gl-dropdown-item>
+ </gl-dropdown>
<gl-button
v-if="hasCloseAndCommentButton && canToggleIssueState"
:loading="isToggleStateButtonLoading"
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index 28d7dec85f4..70167bde188 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -5,6 +5,7 @@ import {
GlDropdownSectionHeader,
GlDropdownItem,
GlIcon,
+ GlModalDirective,
} from '@gitlab/ui';
import permissionsQuery from 'shared_queries/repository/permissions.query.graphql';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
@@ -12,12 +13,15 @@ import { __ } from '../../locale';
import getRefMixin from '../mixins/get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';
import projectShortPathQuery from '../queries/project_short_path.query.graphql';
+import UploadBlobModal from './upload_blob_modal.vue';
const ROW_TYPES = {
header: 'header',
divider: 'divider',
};
+const UPLOAD_BLOB_MODAL_ID = 'modal-upload-blob';
+
export default {
components: {
GlDropdown,
@@ -25,6 +29,7 @@ export default {
GlDropdownSectionHeader,
GlDropdownItem,
GlIcon,
+ UploadBlobModal,
},
apollo: {
projectShortPath: {
@@ -46,6 +51,9 @@ export default {
},
},
},
+ directives: {
+ GlModal: GlModalDirective,
+ },
mixins: [getRefMixin],
props: {
currentPath: {
@@ -63,6 +71,21 @@ export default {
required: false,
default: false,
},
+ canPushCode: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ selectedBranch: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ origionalBranch: {
+ type: String,
+ required: false,
+ default: '',
+ },
newBranchPath: {
type: String,
required: false,
@@ -93,7 +116,13 @@ export default {
required: false,
default: null,
},
+ uploadPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
+ uploadBlobModalId: UPLOAD_BLOB_MODAL_ID,
data() {
return {
projectShortPath: '',
@@ -126,7 +155,10 @@ export default {
);
},
canCreateMrFromFork() {
- return this.userPermissions.forkProject && this.userPermissions.createMergeRequestIn;
+ return this.userPermissions?.forkProject && this.userPermissions?.createMergeRequestIn;
+ },
+ showUploadModal() {
+ return this.canEditTree && !this.$apollo.queries.userPermissions.loading;
},
dropdownItems() {
const items = [];
@@ -149,10 +181,9 @@ export default {
{
attrs: {
href: '#modal-upload-blob',
- 'data-target': '#modal-upload-blob',
- 'data-toggle': 'modal',
},
text: __('Upload file'),
+ modalId: UPLOAD_BLOB_MODAL_ID,
},
{
attrs: {
@@ -253,12 +284,26 @@ export default {
<gl-icon name="chevron-down" :size="16" class="float-left" />
</template>
<template v-for="(item, i) in dropdownItems">
- <component :is="getComponent(item.type)" :key="i" v-bind="item.attrs">
+ <component
+ :is="getComponent(item.type)"
+ :key="i"
+ v-bind="item.attrs"
+ v-gl-modal="item.modalId || null"
+ >
{{ item.text }}
</component>
</template>
</gl-dropdown>
</li>
</ol>
+ <upload-blob-modal
+ v-if="showUploadModal"
+ :modal-id="$options.uploadBlobModalId"
+ :commit-message="__('Upload New File')"
+ :target-branch="selectedBranch"
+ :origional-branch="origionalBranch"
+ :can-push-code="canPushCode"
+ :path="uploadPath"
+ />
</nav>
</template>
diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue
new file mode 100644
index 00000000000..4cdfc5e947a
--- /dev/null
+++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue
@@ -0,0 +1,218 @@
+<script>
+import {
+ GlModal,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlToggle,
+ GlButton,
+ GlAlert,
+} from '@gitlab/ui';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { ContentTypeMultipartFormData } from '~/lib/utils/headers';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { visitUrl, joinPaths } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
+
+const PRIMARY_OPTIONS_TEXT = __('Upload file');
+const SECONDARY_OPTIONS_TEXT = __('Cancel');
+const MODAL_TITLE = __('Upload New File');
+const COMMIT_LABEL = __('Commit message');
+const TARGET_BRANCH_LABEL = __('Target branch');
+const TOGGLE_CREATE_MR_LABEL = __('Start a new merge request with these changes');
+const REMOVE_FILE_TEXT = __('Remove file');
+const NEW_BRANCH_IN_FORK = __(
+ 'A new branch will be created in your fork and a new merge request will be started.',
+);
+const ERROR_MESSAGE = __('Error uploading file. Please try again.');
+
+export default {
+ components: {
+ GlModal,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlToggle,
+ GlButton,
+ UploadDropzone,
+ GlAlert,
+ },
+ i18n: {
+ MODAL_TITLE,
+ COMMIT_LABEL,
+ TARGET_BRANCH_LABEL,
+ TOGGLE_CREATE_MR_LABEL,
+ REMOVE_FILE_TEXT,
+ NEW_BRANCH_IN_FORK,
+ },
+ props: {
+ modalId: {
+ type: String,
+ required: true,
+ },
+ commitMessage: {
+ type: String,
+ required: true,
+ },
+ targetBranch: {
+ type: String,
+ required: true,
+ },
+ origionalBranch: {
+ type: String,
+ required: true,
+ },
+ canPushCode: {
+ type: Boolean,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ commit: this.commitMessage,
+ target: this.targetBranch,
+ createNewMr: true,
+ file: null,
+ filePreviewURL: null,
+ fileBinary: null,
+ loading: false,
+ };
+ },
+ computed: {
+ primaryOptions() {
+ return {
+ text: PRIMARY_OPTIONS_TEXT,
+ attributes: [
+ {
+ variant: 'success',
+ loading: this.loading,
+ disabled: !this.formCompleted || this.loading,
+ },
+ ],
+ };
+ },
+ cancelOptions() {
+ return {
+ text: SECONDARY_OPTIONS_TEXT,
+ attributes: [
+ {
+ disabled: this.loading,
+ },
+ ],
+ };
+ },
+ formattedFileSize() {
+ return numberToHumanSize(this.file.size);
+ },
+ showCreateNewMrToggle() {
+ return this.canPushCode && this.target !== this.origionalBranch;
+ },
+ formCompleted() {
+ return this.file && this.commit && this.target;
+ },
+ },
+ methods: {
+ setFile(file) {
+ this.file = file;
+
+ const fileUurlReader = new FileReader();
+
+ fileUurlReader.readAsDataURL(this.file);
+
+ fileUurlReader.onload = (e) => {
+ this.filePreviewURL = e.target?.result;
+ };
+ },
+ removeFile() {
+ this.file = null;
+ this.filePreviewURL = null;
+ },
+ uploadFile() {
+ this.loading = true;
+
+ const {
+ $route: {
+ params: { path },
+ },
+ } = this;
+ const uploadPath = joinPaths(this.path, path);
+
+ const formData = new FormData();
+ formData.append('branch_name', this.target);
+ formData.append('create_merge_request', this.createNewMr);
+ formData.append('commit_message', this.commit);
+ formData.append('file', this.file);
+
+ return axios
+ .post(uploadPath, formData, {
+ headers: {
+ ...ContentTypeMultipartFormData,
+ },
+ })
+ .then((response) => {
+ visitUrl(response.data.filePath);
+ })
+ .catch(() => {
+ this.loading = false;
+ createFlash(ERROR_MESSAGE);
+ });
+ },
+ },
+};
+</script>
+<template>
+ <gl-form>
+ <gl-modal
+ :modal-id="modalId"
+ :title="$options.i18n.MODAL_TITLE"
+ :action-primary="primaryOptions"
+ :action-cancel="cancelOptions"
+ @primary.prevent="uploadFile"
+ >
+ <upload-dropzone class="gl-h-200! gl-mb-4" single-file-selection @change="setFile">
+ <div
+ v-if="file"
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
+ >
+ <img v-if="filePreviewURL" :src="filePreviewURL" class="gl-h-11" />
+ <div>{{ formattedFileSize }}</div>
+ <div>{{ file.name }}</div>
+ <gl-button
+ category="tertiary"
+ variant="confirm"
+ :disabled="loading"
+ @click="removeFile"
+ >{{ $options.i18n.REMOVE_FILE_TEXT }}</gl-button
+ >
+ </div>
+ </upload-dropzone>
+ <gl-form-group :label="$options.i18n.COMMIT_LABEL" label-for="commit_message">
+ <gl-form-textarea v-model="commit" name="commit_message" :disabled="loading" />
+ </gl-form-group>
+ <gl-form-group
+ v-if="canPushCode"
+ :label="$options.i18n.TARGET_BRANCH_LABEL"
+ label-for="branch_name"
+ >
+ <gl-form-input v-model="target" :disabled="loading" name="branch_name" />
+ </gl-form-group>
+ <gl-toggle
+ v-if="showCreateNewMrToggle"
+ v-model="createNewMr"
+ :disabled="loading"
+ :label="$options.i18n.TOGGLE_CREATE_MR_LABEL"
+ />
+ <gl-alert v-if="!canPushCode" variant="info" :dismissible="false" class="gl-mt-3">
+ {{ $options.i18n.NEW_BRANCH_IN_FORK }}
+ </gl-alert>
+ </gl-modal>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 747b85f5c1c..fafc6e8eb89 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -1,8 +1,8 @@
import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import { escapeFileUrl } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
-import { parseBoolean } from '../lib/utils/common_utils';
-import { escapeFileUrl } from '../lib/utils/url_utility';
-import { __ } from '../locale';
import App from './components/app.vue';
import Breadcrumbs from './components/breadcrumbs.vue';
import DirectoryDownloadLinks from './components/directory_download_links.vue';
@@ -55,6 +55,8 @@ export default function setupVueRepositoryList() {
const {
canCollaborate,
canEditTree,
+ canPushCode,
+ selectedBranch,
newBranchPath,
newTagPath,
newBlobPath,
@@ -65,8 +67,7 @@ export default function setupVueRepositoryList() {
newDirPath,
} = breadcrumbEl.dataset;
- router.afterEach(({ params: { path = '/' } }) => {
- updateFormAction('.js-upload-blob-form', uploadPath, path);
+ router.afterEach(({ params: { path } }) => {
updateFormAction('.js-create-dir-form', newDirPath, path);
});
@@ -81,12 +82,16 @@ export default function setupVueRepositoryList() {
currentPath: this.$route.params.path,
canCollaborate: parseBoolean(canCollaborate),
canEditTree: parseBoolean(canEditTree),
+ canPushCode: parseBoolean(canPushCode),
+ origionalBranch: ref,
+ selectedBranch,
newBranchPath,
newTagPath,
newBlobPath,
forkNewBlobPath,
forkNewDirectoryPath,
forkUploadBlobPath,
+ uploadPath,
},
});
},
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index b1bd15e0bd7..cb5050fc578 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -394,32 +394,6 @@ table {
}
.comment-type-dropdown {
- .btn-success {
- width: auto;
- }
-
- .dropdown-toggle {
- float: right;
-
- i {
- color: $white;
- padding-right: 2px;
- margin-top: 2px;
- }
-
- &[disabled] {
- i {
- color: $gl-text-color-disabled;
- }
- }
- }
-
- .dropdown-menu {
- top: initial;
- bottom: 100%;
- width: 298px;
- }
-
@include media-breakpoint-down(xs) {
display: flex;
width: 100%;
diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb
index 317514d088b..63d64745a96 100644
--- a/app/experiments/application_experiment.rb
+++ b/app/experiments/application_experiment.rb
@@ -3,19 +3,21 @@
class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/NamespacedClass
def enabled?
return false if Feature::Definition.get(feature_flag_name).nil? # there has to be a feature flag yaml file
- return false unless Gitlab.dev_env_or_com? # we're in an environment that allows experiments
+ return false unless Gitlab.dev_env_or_com? # we have to be in an environment that allows experiments
+ # the feature flag has to be rolled out
Feature.get(feature_flag_name).state != :off # rubocop:disable Gitlab/AvoidFeatureGet
end
def publish(_result)
track(:assignment) # track that we've assigned a variant for this context
- Gon.global.push({ experiment: { name => signature } }, true) # push to client
+ Gon.global.push({ experiment: { name => signature } }, true) # push the experiment data to the client
end
def track(action, **event_args)
- return unless should_track? # no events for opted out actors or excluded subjects
+ return unless should_track? # don't track events for excluded contexts
+ # track the event, and mix in the experiment signature data
Gitlab::Tracking.event(name, action.to_s, **event_args.merge(
context: (event_args[:context] || []) << SnowplowTracker::SelfDescribingJson.new(
'iglu:com.gitlab/gitlab_experiment/jsonschema/0-3-0', signature
@@ -59,62 +61,4 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp
nil # Returning nil vs. :control is important for not caching and rollouts.
end
-
- # Cache is an implementation on top of Gitlab::Redis::SharedState that also
- # adheres to the ActiveSupport::Cache::Store interface and uses the redis
- # hash data type.
- #
- # Since Gitlab::Experiment can use any type of caching layer, utilizing the
- # long lived shared state interface here gives us an efficient way to store
- # context keys and the variant they've been assigned -- while also giving us
- # a simple way to clean up an experiments data upon resolution.
- #
- # The data structure:
- # key: experiment.name
- # fields: context key => variant name
- #
- # The keys are expected to be `experiment_name:context_key`, which is the
- # default cache key strategy. So running `cache.fetch("foo:bar", "value")`
- # would create/update a hash with the key of "foo", with a field named
- # "bar" that has "value" assigned to it.
- class Cache < ActiveSupport::Cache::Store # rubocop:disable Gitlab/NamespacedClass
- # Clears the entire cache for a given experiment. Be careful with this
- # since it would reset all resolved variants for the entire experiment.
- def clear(key:)
- key = hkey(key)[0] # extract only the first part of the key
- pool do |redis|
- case redis.type(key)
- when 'hash', 'none' then redis.del(key)
- else raise ArgumentError, 'invalid call to clear a non-hash cache key'
- end
- end
- end
-
- private
-
- def pool
- raise ArgumentError, 'missing block' unless block_given?
-
- Gitlab::Redis::SharedState.with { |redis| yield redis }
- end
-
- def hkey(key)
- key.to_s.split(':') # this assumes the default strategy in gitlab-experiment
- end
-
- def read_entry(key, **options)
- value = pool { |redis| redis.hget(*hkey(key)) }
- value.nil? ? nil : ActiveSupport::Cache::Entry.new(value)
- end
-
- def write_entry(key, entry, **options)
- return false if entry.value.blank? # don't cache any empty values
-
- pool { |redis| redis.hset(*hkey(key), entry.value) }
- end
-
- def delete_entry(key, **options)
- pool { |redis| redis.hdel(*hkey(key)) }
- end
- end
end
diff --git a/app/graphql/mutations/merge_requests/set_wip.rb b/app/graphql/mutations/merge_requests/set_wip.rb
index 0b5c20de377..beb042ce93f 100644
--- a/app/graphql/mutations/merge_requests/set_wip.rb
+++ b/app/graphql/mutations/merge_requests/set_wip.rb
@@ -9,7 +9,7 @@ module Mutations
GraphQL::BOOLEAN_TYPE,
required: true,
description: <<~DESC
- Whether or not to set the merge request as a WIP.
+ Whether or not to set the merge request as a draft.
DESC
def resolve(project_path:, iid:, wip: nil)
diff --git a/app/graphql/resolvers/concerns/resolves_snippets.rb b/app/graphql/resolvers/concerns/resolves_snippets.rb
index 0bc38188b9a..445f3567b1d 100644
--- a/app/graphql/resolvers/concerns/resolves_snippets.rb
+++ b/app/graphql/resolvers/concerns/resolves_snippets.rb
@@ -8,7 +8,7 @@ module ResolvesSnippets
argument :ids, [::Types::GlobalIDType[::Snippet]],
required: false,
- description: 'Array of global snippet ids, e.g., "gid://gitlab/ProjectSnippet/1".'
+ description: 'Array of global snippet IDs. For example, `gid://gitlab/ProjectSnippet/1`.'
argument :visibility, Types::Snippets::VisibilityScopesEnum,
required: false,
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index 10f324e901a..d021ae5ce9e 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -108,6 +108,10 @@ module Types
null: false, calls_gitaly: true,
method: :target_branch_exists?,
description: 'Indicates if the target branch of the merge request exists.'
+ field :diverged_from_target_branch, GraphQL::BOOLEAN_TYPE,
+ null: false, calls_gitaly: true,
+ method: :diverged_from_target_branch?,
+ description: 'Indicates if the source branch is behind the target branch.'
field :mergeable_discussions_state, GraphQL::BOOLEAN_TYPE, null: true,
description: 'Indicates if all discussions in the merge request have been resolved, allowing the merge request to be merged.'
field :web_url, GraphQL::STRING_TYPE, null: true,
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 48b9900fe36..74818bfcd42 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -85,7 +85,7 @@ module Types
field :instance_statistics_measurements, Types::Admin::Analytics::UsageTrends::MeasurementType.connection_type,
null: true,
description: 'Get statistics on the instance.',
- deprecated: { reason: 'This field was renamed. Use the `usageTrendsMeasurements` field instead.', milestone: '13.10' },
+ deprecated: { reason: 'This field was renamed. Use the `usageTrendsMeasurements` field instead', milestone: '13.10' },
resolver: Resolvers::Admin::Analytics::UsageTrends::MeasurementsResolver
field :usage_trends_measurements, Types::Admin::Analytics::UsageTrends::MeasurementType.connection_type,
diff --git a/app/graphql/types/todo_state_enum.rb b/app/graphql/types/todo_state_enum.rb
index 29a28b5208d..604e2a62f70 100644
--- a/app/graphql/types/todo_state_enum.rb
+++ b/app/graphql/types/todo_state_enum.rb
@@ -2,7 +2,7 @@
module Types
class TodoStateEnum < BaseEnum
- value 'pending'
- value 'done'
+ value 'pending', description: "The state of the todo is pending."
+ value 'done', description: "The state of the todo is done."
end
end
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index b050f533d77..b795851ba30 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -131,6 +131,8 @@ module TreeHelper
def breadcrumb_data_attributes
attrs = {
+ selected_branch: selected_branch,
+ can_push_code: can?(current_user, :push_code, @project).to_s,
can_collaborate: can_collaborate_with_project?(@project).to_s,
new_blob_path: project_new_blob_path(@project, @ref),
upload_path: project_create_blob_path(@project, @ref),
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 8bb009bfd17..bbf2f242a63 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -86,7 +86,7 @@
= render_if_exists 'projects/sidebar/repository_locked_files'
- if project_nav_tab? :issues
- = nav_link(controller: @project.issues_enabled? ? ['projects/issues', :labels, :milestones, :boards] : 'projects/issues') do
+ = nav_link(controller: @project.issues_enabled? ? ['projects/issues', :labels, :milestones, :boards, :iterations] : 'projects/issues') do
= link_to project_issues_path(@project), class: 'shortcuts-issues qa-issues-item' do
.nav-icon-container
= sprite_icon('issues')
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 30d885964b5..0369ee50c40 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -21,5 +21,4 @@
#js-tree-list{ data: vue_file_list_data(project, ref) }
- if can_edit_tree?
- = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post
= render 'projects/blob/new_dir'
diff --git a/changelogs/unreleased/254280-replace-bootstrap-modal-trigger-in-app-assets-javascripts-blob-blo.yml b/changelogs/unreleased/254280-replace-bootstrap-modal-trigger-in-app-assets-javascripts-blob-blo.yml
new file mode 100644
index 00000000000..efb9ccf59a4
--- /dev/null
+++ b/changelogs/unreleased/254280-replace-bootstrap-modal-trigger-in-app-assets-javascripts-blob-blo.yml
@@ -0,0 +1,5 @@
+---
+title: Migrate bootstrap modal to GlModal for repo single file uploads
+merge_request: 53623
+author:
+type: changed
diff --git a/changelogs/unreleased/275818-be-add-info-error-messages-to-security-widget-summary-diverged.yml b/changelogs/unreleased/275818-be-add-info-error-messages-to-security-widget-summary-diverged.yml
new file mode 100644
index 00000000000..c0fd71b3e58
--- /dev/null
+++ b/changelogs/unreleased/275818-be-add-info-error-messages-to-security-widget-summary-diverged.yml
@@ -0,0 +1,6 @@
+---
+title: Add divergedFromTargetBranch field to MergeRequestType to indicate the target
+ branch has diverged from the source branch
+merge_request: 53759
+author:
+type: changed
diff --git a/changelogs/unreleased/290288-composer-cache-build-pages-task.yml b/changelogs/unreleased/290288-composer-cache-build-pages-task.yml
new file mode 100644
index 00000000000..56df4303333
--- /dev/null
+++ b/changelogs/unreleased/290288-composer-cache-build-pages-task.yml
@@ -0,0 +1,5 @@
+---
+title: Add composer cache rake task
+merge_request: 53772
+author:
+type: added
diff --git a/changelogs/unreleased/322019-iterations-list-and-report-views-missing-left-sidebar-status.yml b/changelogs/unreleased/322019-iterations-list-and-report-views-missing-left-sidebar-status.yml
new file mode 100644
index 00000000000..48e391e7b32
--- /dev/null
+++ b/changelogs/unreleased/322019-iterations-list-and-report-views-missing-left-sidebar-status.yml
@@ -0,0 +1,5 @@
+---
+title: Expand left sidebar `Issues` when viewing project iterations
+merge_request: 54815
+author:
+type: fixed
diff --git a/changelogs/unreleased/epic_count_query.yml b/changelogs/unreleased/epic_count_query.yml
new file mode 100644
index 00000000000..e449caa1fad
--- /dev/null
+++ b/changelogs/unreleased/epic_count_query.yml
@@ -0,0 +1,6 @@
+---
+title: Added composite index to epic_issues table and improved performance of loading
+ bigger epic roadmaps
+merge_request: 54677
+author:
+type: performance
diff --git a/changelogs/unreleased/remove-bootstrap-dropdowns-from-note-components.yml b/changelogs/unreleased/remove-bootstrap-dropdowns-from-note-components.yml
new file mode 100644
index 00000000000..b55e6b2da7d
--- /dev/null
+++ b/changelogs/unreleased/remove-bootstrap-dropdowns-from-note-components.yml
@@ -0,0 +1,6 @@
+---
+title: Migrated Bootstrap dropdown to GitLab UI GlDropdown used for comment submit
+ button
+merge_request: 50933
+author:
+type: other
diff --git a/config/initializers/gitlab_experiment.rb b/config/initializers/gitlab_experiment.rb
index 40b4c0dc4ee..a312755f300 100644
--- a/config/initializers/gitlab_experiment.rb
+++ b/config/initializers/gitlab_experiment.rb
@@ -2,5 +2,7 @@
Gitlab::Experiment.configure do |config|
config.base_class = 'ApplicationExperiment'
- config.cache = ApplicationExperiment::Cache.new
+ config.cache = Gitlab::Experiment::Cache::RedisHashStore.new(
+ pool: ->(&block) { Gitlab::Redis::SharedState.with { |redis| block.call(redis) } }
+ )
end
diff --git a/danger/product_intelligence/Dangerfile b/danger/product_intelligence/Dangerfile
index 057d3a6b909..e9f6a867fec 100644
--- a/danger/product_intelligence/Dangerfile
+++ b/danger/product_intelligence/Dangerfile
@@ -10,15 +10,10 @@ Please check the ~"product intelligence" [guide](https://docs.gitlab.com/ee/deve
MSG
-UPDATE_METRICS_DEFINITIONS_MESSAGE = <<~MSG
- When adding, changing, or updating metrics, please update the [Event dictionary Usage Ping table](https://about.gitlab.com/handbook/product/product-analytics-guide#event-dictionary).
-
-MSG
-
ENGINEERS_GROUP = '@gitlab-org/growth/product-intelligence/engineers'
UPDATE_DICTIONARY_MESSAGE = <<~MSG
- When updating metrics definitions, please update [Metrics Dictionary](https://docs.gitlab.com/ee/development/usage_ping/dictionary.html)
+ When adding, changing, or updating metrics, please update the [Metrics Dictionary](https://docs.gitlab.com/ee/development/usage_ping/dictionary.html)
```shell
bundle exec rake gitlab:usage_data:generate_metrics_dictionary
@@ -75,7 +70,6 @@ if matching_changed_files.any?
end
warn format(CHANGED_FILES_MESSAGE, changed_files: helper.markdown_list(matching_changed_files), engineers_group: mention)
- warn format(UPDATE_METRICS_DEFINITIONS_MESSAGE) if usage_data_changed_files.any?
fail format(UPDATE_DICTIONARY_MESSAGE) if metrics_changed_files.any? && dictionary_changed_file.empty?
diff --git a/db/migrate/20210219111040_add_epic_issue_composite_index.rb b/db/migrate/20210219111040_add_epic_issue_composite_index.rb
new file mode 100644
index 00000000000..f1344baf0c7
--- /dev/null
+++ b/db/migrate/20210219111040_add_epic_issue_composite_index.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class AddEpicIssueCompositeIndex < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ INDEX_NAME = 'index_epic_issues_on_epic_id_and_issue_id'
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :epic_issues, [:epic_id, :issue_id], name: INDEX_NAME
+ end
+
+ def down
+ remove_concurrent_index_by_name :epic_issues, INDEX_NAME
+ end
+end
diff --git a/db/schema_migrations/20210219111040 b/db/schema_migrations/20210219111040
new file mode 100644
index 00000000000..a006ece2f6c
--- /dev/null
+++ b/db/schema_migrations/20210219111040
@@ -0,0 +1 @@
+546802f93f64e346b066438e78ace5d2dc54de8a5f6234c2d01296a239cfe74c \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 09f1b91dc28..fe14f23af3a 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -22125,6 +22125,8 @@ CREATE INDEX index_environments_on_state_and_auto_stop_at ON environments USING
CREATE INDEX index_epic_issues_on_epic_id ON epic_issues USING btree (epic_id);
+CREATE INDEX index_epic_issues_on_epic_id_and_issue_id ON epic_issues USING btree (epic_id, issue_id);
+
CREATE UNIQUE INDEX index_epic_issues_on_issue_id ON epic_issues USING btree (issue_id);
CREATE INDEX index_epic_metrics ON epic_metrics USING btree (epic_id);
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 1bfd50e7246..385a2ea0e29 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -62,6 +62,17 @@ Get information about current user.
Fields related to design management.
+### DevopsAdoptionSegments
+
+Get configured DevOps adoption segments on the instance.
+
+| Name | Description | Type |
+| ----- | ---- | ----------- |
+| `after` | Returns the elements in the list that come after the specified cursor. | String |
+| `before` | Returns the elements in the list that come before the specified cursor. | String |
+| `first` | Returns the first _n_ elements from the list. | Int |
+| `last` | Returns the last _n_ elements from the list. | Int |
+
### Echo
Text to echo back.
@@ -90,6 +101,20 @@ Find a group.
Fields related to Instance Security Dashboard.
+### InstanceStatisticsMeasurements
+
+Get statistics on the instance. Deprecated in 13.10: This field was renamed. Use the `usageTrendsMeasurements` field instead.
+
+| Name | Description | Type |
+| ----- | ---- | ----------- |
+| `after` | Returns the elements in the list that come after the specified cursor. | String |
+| `before` | Returns the elements in the list that come before the specified cursor. | String |
+| `first` | Returns the first _n_ elements from the list. | Int |
+| `identifier` | The type of measurement/statistics to retrieve. | MeasurementIdentifier! |
+| `last` | Returns the last _n_ elements from the list. | Int |
+| `recordedAfter` | Measurement recorded after this date. | Time |
+| `recordedBefore` | Measurement recorded before this date. | Time |
+
### Issue
Find an Issue.
@@ -142,6 +167,33 @@ Find a project.
| ----- | ---- | ----------- |
| `fullPath` | The full path of the project, group or namespace, e.g., `gitlab-org/gitlab-foss`. | ID! |
+### Projects
+
+Find projects visible to the current user.
+
+| Name | Description | Type |
+| ----- | ---- | ----------- |
+| `after` | Returns the elements in the list that come after the specified cursor. | String |
+| `before` | Returns the elements in the list that come before the specified cursor. | String |
+| `first` | Returns the first _n_ elements from the list. | Int |
+| `ids` | Filter projects by IDs. | ID! => Array |
+| `last` | Returns the last _n_ elements from the list. | Int |
+| `membership` | Limit projects that the current user is a member of. | Boolean |
+| `search` | Search query for project name, path, or description. | String |
+| `searchNamespaces` | Include namespace in project search. | Boolean |
+| `sort` | Sort order of results. | String |
+
+### RunnerPlatforms
+
+Supported runner platforms.
+
+| Name | Description | Type |
+| ----- | ---- | ----------- |
+| `after` | Returns the elements in the list that come after the specified cursor. | String |
+| `before` | Returns the elements in the list that come before the specified cursor. | String |
+| `first` | Returns the first _n_ elements from the list. | Int |
+| `last` | Returns the last _n_ elements from the list. | Int |
+
### RunnerSetup
Get runner setup instructions.
@@ -153,6 +205,37 @@ Get runner setup instructions.
| `platform` | Platform to generate the instructions for. | String! |
| `projectId` | Project to register the runner for. | ProjectID |
+### Snippets
+
+Find Snippets visible to the current user.
+
+| Name | Description | Type |
+| ----- | ---- | ----------- |
+| `after` | Returns the elements in the list that come after the specified cursor. | String |
+| `authorId` | The ID of an author. | UserID |
+| `before` | Returns the elements in the list that come before the specified cursor. | String |
+| `explore` | Explore personal snippets. | Boolean |
+| `first` | Returns the first _n_ elements from the list. | Int |
+| `ids` | Array of global snippet IDs. For example, `gid://gitlab/ProjectSnippet/1`. | SnippetID! => Array |
+| `last` | Returns the last _n_ elements from the list. | Int |
+| `projectId` | The ID of a project. | ProjectID |
+| `type` | The type of snippet. | TypeEnum |
+| `visibility` | The visibility of the snippet. | VisibilityScopesEnum |
+
+### UsageTrendsMeasurements
+
+Get statistics on the instance.
+
+| Name | Description | Type |
+| ----- | ---- | ----------- |
+| `after` | Returns the elements in the list that come after the specified cursor. | String |
+| `before` | Returns the elements in the list that come before the specified cursor. | String |
+| `first` | Returns the first _n_ elements from the list. | Int |
+| `identifier` | The type of measurement/statistics to retrieve. | MeasurementIdentifier! |
+| `last` | Returns the last _n_ elements from the list. | Int |
+| `recordedAfter` | Measurement recorded after this date. | Time |
+| `recordedBefore` | Measurement recorded before this date. | Time |
+
### User
Find a user.
@@ -162,6 +245,67 @@ Find a user.
| `id` | ID of the User. | UserID |
| `username` | Username of the User. | String |
+### Users
+
+Find users.
+
+| Name | Description | Type |
+| ----- | ---- | ----------- |
+| `admins` | Return only admin users. | Boolean |
+| `after` | Returns the elements in the list that come after the specified cursor. | String |
+| `before` | Returns the elements in the list that come before the specified cursor. | String |
+| `first` | Returns the first _n_ elements from the list. | Int |
+| `ids` | List of user Global IDs. | ID! => Array |
+| `last` | Returns the last _n_ elements from the list. | Int |
+| `search` | Query to search users by name, username, or primary email. | String |
+| `sort` | Sort users by this criteria. | Sort |
+| `usernames` | List of usernames. | String! => Array |
+
+### Vulnerabilities
+
+Vulnerabilities reported on projects on the current user's instance security dashboard.
+
+| Name | Description | Type |
+| ----- | ---- | ----------- |
+| `after` | Returns the elements in the list that come after the specified cursor. | String |
+| `before` | Returns the elements in the list that come before the specified cursor. | String |
+| `first` | Returns the first _n_ elements from the list. | Int |
+| `hasIssues` | Returns only the vulnerabilities which have linked issues. | Boolean |
+| `hasResolution` | Returns only the vulnerabilities which have been resolved on default branch. | Boolean |
+| `last` | Returns the last _n_ elements from the list. | Int |
+| `projectId` | Filter vulnerabilities by project. | ID! => Array |
+| `reportType` | Filter vulnerabilities by report type. | VulnerabilityReportType! => Array |
+| `scanner` | Filter vulnerabilities by VulnerabilityScanner.externalId. | String! => Array |
+| `severity` | Filter vulnerabilities by severity. | VulnerabilitySeverity! => Array |
+| `sort` | List vulnerabilities by sort order. | VulnerabilitySort |
+| `state` | Filter vulnerabilities by state. | VulnerabilityState! => Array |
+
+### VulnerabilitiesCountByDay
+
+Number of vulnerabilities per day for the projects on the current user's instance security dashboard.
+
+| Name | Description | Type |
+| ----- | ---- | ----------- |
+| `after` | Returns the elements in the list that come after the specified cursor. | String |
+| `before` | Returns the elements in the list that come before the specified cursor. | String |
+| `endDate` | Last day for which to fetch vulnerability history. | ISO8601Date! |
+| `first` | Returns the first _n_ elements from the list. | Int |
+| `last` | Returns the last _n_ elements from the list. | Int |
+| `startDate` | First day for which to fetch vulnerability history. | ISO8601Date! |
+
+### VulnerabilitiesCountByDayAndSeverity
+
+Number of vulnerabilities per severity level, per day, for the projects on the current user's instance security dashboard. Deprecated in 13.3: Use `vulnerabilitiesCountByDay`.
+
+| Name | Description | Type |
+| ----- | ---- | ----------- |
+| `after` | Returns the elements in the list that come after the specified cursor. | String |
+| `before` | Returns the elements in the list that come before the specified cursor. | String |
+| `endDate` | Last day for which to fetch vulnerability history. | ISO8601Date! |
+| `first` | Returns the first _n_ elements from the list. | Int |
+| `last` | Returns the last _n_ elements from the list. | Int |
+| `startDate` | First day for which to fetch vulnerability history. | ISO8601Date! |
+
### Vulnerability
Find a vulnerability.
@@ -2474,6 +2618,7 @@ Autogenerated return type of MarkAsSpamSnippet.
| `diffStatsSummary` | DiffStatsSummary | Summary of which files were changed in this merge request. |
| `discussionLocked` | Boolean! | Indicates if comments on the merge request are locked to members only. |
| `discussions` | DiscussionConnection! | All discussions on this noteable. |
+| `divergedFromTargetBranch` | Boolean! | Indicates if the source branch is behind the target branch. |
| `downvotes` | Int! | Number of downvotes for the merge request. |
| `forceRemoveSourceBranch` | Boolean | Indicates if the project settings will lead to source branch deletion after merge. |
| `hasCi` | Boolean! | Indicates if the merge request has CI. |
@@ -5629,8 +5774,8 @@ State of a test report.
| Value | Description |
| ----- | ----------- |
-| `done` | |
-| `pending` | |
+| `done` | The state of the todo is done. |
+| `pending` | The state of the todo is pending. |
### TodoTargetEnum
diff --git a/doc/development/fe_guide/editor_lite.md b/doc/development/fe_guide/editor_lite.md
index 8154f0ee5c6..5ad0c753ced 100644
--- a/doc/development/fe_guide/editor_lite.md
+++ b/doc/development/fe_guide/editor_lite.md
@@ -6,114 +6,139 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Editor Lite **(FREE)**
-## Background
+**Editor Lite** provides the editing experience at GitLab. This thin wrapper around
+[the Monaco editor](https://microsoft.github.io/monaco-editor/) provides necessary
+helpers and abstractions, and extends Monaco [using extensions](#extensions). Multiple
+GitLab features use it, including:
-**Editor Lite** is a technological product driving features like [Web Editor](../../user/project/repository/web_editor.md), [Snippets](../../user/snippets.md), and [CI Linter](../../ci/lint.md). Editor Lite is the driving technology for any single-file editing experience across the product.
-
-Editor Lite is a thin wrapper around [the Monaco editor](https://microsoft.github.io/monaco-editor/index.html) that provides the necessary helpers and abstractions and extends Monaco using extensions.
+- [Web IDE](../../user/project/web_ide/index.md)
+- [CI Linter](../../ci/lint.md)
+- [Snippets](../../user/snippets.md)
+- [Web Editor](../../user/project/repository/web_editor.md)
## How to use Editor Lite
-Editor Lite is framework-agnostic and can be used in any application, whether it's Rails or Vue. For the convenience of integration, we have the dedicated `<editor-lite>` Vue component, but in general, the integration of Editor Lite is pretty straightforward:
+Editor Lite is framework-agnostic and can be used in any application, including both
+Rails and Vue. To help with integration, we have the dedicated `<editor-lite>`
+Vue component, but the integration of Editor Lite is generally straightforward:
1. Import Editor Lite:
-```javascript
-import EditorLite from '~/editor/editor_lite';
-```
+ ```javascript
+ import EditorLite from '~/editor/editor_lite';
+ ```
1. Initialize global editor for the view:
-```javascript
-const editor = new EditorLite({
- // Editor Options.
- // The list of all accepted options can be found at
- // https://microsoft.github.io/monaco-editor/api/enums/monaco.editor.editoroption.html
-});
-```
+ ```javascript
+ const editor = new EditorLite({
+ // Editor Options.
+ // The list of all accepted options can be found at
+ // https://microsoft.github.io/monaco-editor/api/enums/monaco.editor.editoroption.html
+ });
+ ```
1. Create an editor's instance:
-```javascript
-editor.createInstance({
- // Editor Lite configuration options.
-})
-```
+ ```javascript
+ editor.createInstance({
+ // Editor Lite configuration options.
+ })
+ ```
An instance of Editor Lite accepts the following configuration options:
| Option | Required? | Description |
-| ---- | ---- | ---- |
-| `el` | `true` | `HTML Node`: element on which to render the editor |
-| `blobPath` | `false` | `String`: the name of a file to render in the editor. It is used to identify the correct syntax highlighter to use with that or another file type. Can accept wildcard as in `*.js` when the actual filename isn't known or doesn't play any role |
-| `blobContent` | `false` | `String`: the initial content to be rendered in the editor |
-| `extensions` | `false` | `Array`: extensions to use in this instance |
-| `blobGlobalId` | `false` | `String`: auto-generated property.<br>**Note:** this prop might go away in the future. Do not pass `blobGlobalId` unless you know what you're doing.|
-| [Editor Options](https://microsoft.github.io/monaco-editor/api/enums/monaco.editor.editoroption.html) | `false` | `Object(s)`: any prop outside of the list above is treated as an Editor Option for this particular instance. This way, one can override global Editor Options on the instance level. |
+| -------------- | ------- | ---- |
+| `el` | `true` | `HTML Node`: The element on which to render the editor. |
+| `blobPath` | `false` | `String`: The name of a file to render in the editor, used to identify the correct syntax highlighter to use with that file, or another file type. Can accept wildcards like `*.js` when the actual filename isn't known or doesn't play any role. |
+| `blobContent` | `false` | `String`: The initial content to render in the editor. |
+| `extensions` | `false` | `Array`: Extensions to use in this instance. |
+| `blobGlobalId` | `false` | `String`: An auto-generated property.<br>**Note:** This property may go away in the future. Do not pass `blobGlobalId` unless you know what you're doing.|
+| Editor Options | `false` | `Object(s)`: Any property outside of the list above is treated as an Editor Option for this particular instance. Use this field to override global Editor Options on the instance level. A full [index of Editor Options](https://microsoft.github.io/monaco-editor/api/enums/monaco.editor.editoroption.html) is available. |
## API
-The editor follows the same public API as [provided by Monaco editor](https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.istandalonecodeeditor.html) with just a few additional functions on the instance level:
+The editor uses the same public API as
+[provided by Monaco editor](https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.istandalonecodeeditor.html)
+with additional functions on the instance level:
-| Function | Arguments | Description
-| ----- | ----- | ----- |
-| `updateModelLanguage` | `path`: String | Updates the instance's syntax highlighting to follow the extension of the passed `path`. Available only on _instance_ level|
-| `use` | Array of objects | Array of **extensions** to apply to the instance. Accepts only the array of _objects_, which means that the extensions' ES6 modules should be fetched and resolved in your views/components before being passed to `use`. This prop is available on _instance_ (applies extension to this particular instance) and _global editor_ (applies the same extension to all instances) levels. |
+| Function | Arguments | Description
+| --------------------- | ----- | ----- |
+| `updateModelLanguage` | `path`: String | Updates the instance's syntax highlighting to follow the extension of the passed `path`. Available only on the instance level.|
+| `use` | Array of objects | Array of extensions to apply to the instance. Accepts only the array of _objects_. You must fetch the extensions' ES6 modules must be fetched and resolved in your views or components before they are passed to `use`. This property is available on _instance_ (applies extension to this particular instance) and _global editor_ (applies the same extension to all instances) levels. |
| Monaco Editor options | See [documentation](https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.istandalonecodeeditor.html) | Default Monaco editor options |
## Tips
1. Editor's loading state.
-Editor Lite comes with the loading state built-in, making spinners and loaders rarely needed in HTML. To benefit the built-in loading state, set the `data-editor-loading` property on the HTML element that is supposed to contain the editor. Editor Lite shows the loader automatically while it's bootstrapping.
-![Editor Lite: loading state](img/editor_lite_loading.png)
+ The loading state is built in to Editor Lite, making spinners and loaders
+ rarely needed in HTML. To benefit the built-in loading state, set the `data-editor-loading`
+ property on the HTML element that should contain the editor. When bootstrapping,
+ Editor Lite shows the loader automatically.
+
+ ![Editor Lite: loading state](img/editor_lite_loading.png)
1. Update syntax highlighting if the filename changes.
-```javascript
-// fileNameEl here is the HTML input element that contains the file name
-fileNameEl.addEventListener('change', () => {
- this.editor.updateModelLanguage(fileNameEl.value);
-});
-```
+ ```javascript
+ // fileNameEl here is the HTML input element that contains the file name
+ fileNameEl.addEventListener('change', () => {
+ this.editor.updateModelLanguage(fileNameEl.value);
+ });
+ ```
1. Get the editor's content.
-We might set up listeners on the editor for every change but it rapidly can become an expensive operation. Instead , we can get editor's content when it's needed. For example on a form's submission:
+ We may set up listeners on the editor for every change, but it rapidly can become
+ an expensive operation. Instead, get the editor's content when it's needed.
+ For example, on a form's submission:
-```javascript
-form.addEventListener('submit', () => {
- my_content_variable = this.editor.getValue();
-});
-```
+ ```javascript
+ form.addEventListener('submit', () => {
+ my_content_variable = this.editor.getValue();
+ });
+ ```
1. Performance
-Even though Editor Lite itself is extremely slim, it still depends on Monaco editor. Monaco is not an easily tree-shakeable module. Hence, every time you add Editor Lite to a view, the JavaScript bundle's size significantly increases, affecting your view's loading performance. It is recommended to import the editor on demand on those views where it is not 100% certain that the editor is needed. Or if the editor is a secondary element of the view. Loading Editor Lite on demand is no different from loading any other module:
+ Even though Editor Lite itself is extremely slim, it still depends on Monaco editor,
+ which adds weight. Every time you add Editor Lite to a view, the JavaScript bundle's
+ size significantly increases, affecting your view's loading performance. We recommend
+ you import the editor on demand if either:
-```javascript
-someActionFunction() {
- import(/* webpackChunkName: 'EditorLite' */ '~/editor/editor_lite').
- then(({ default: EditorLite }) => {
- const editor = new EditorLite();
- ...
- });
- ...
-}
-```
+ - You're uncertain if the view needs the editor.
+ - The editor is a secondary element of the view.
+
+ Loading Editor Lite on demand is handled like loading any other module:
+
+ ```javascript
+ someActionFunction() {
+ import(/* webpackChunkName: 'EditorLite' */ '~/editor/editor_lite').
+ then(({ default: EditorLite }) => {
+ const editor = new EditorLite();
+ ...
+ });
+ ...
+ }
+ ```
## Extensions
-Editor Lite has been built to provide a universal, extensible editing tool to the whole product,
-which would not depend on any particular group. Even though the Editor Lite's core is owned by
+Editor Lite provides a universal, extensible editing tool to the whole product,
+and doesn't depend on any particular group. Even though the Editor Lite's core is owned by
[Create::Editor FE Team](https://about.gitlab.com/handbook/engineering/development/dev/create-editor/),
-the main functional elements — extensions — can be owned by any group. Editor Lite extensions' main idea
-is that the core of the editor remains very slim and stable. At the same time, whatever new functionality
-is needed can be added as an extension to this core, without touching the core itself. Any group is allowed
-to build and own new editing functionality without being afraid of it being broken or overridden with
-the Editor Lite changes.
+any group can own the extensions—the main functional elements. The goal of
+Editor Lite extensions is to keep the editor's core slim and stable. Any
+needed features can be added as extensions to this core. Any group can
+build and own new editing features without worrying about changes to Editor Lite
+breaking or overriding them.
-Structurally, the complete implementation of Editor Lite could be presented as the following diagram:
+You can depend on other modules in your extensions. This organization helps keep
+the size of Editor Lite's core at bay by importing dependencies only when needed.
+
+Structurally, the complete implementation of Editor Lite can be presented as this diagram:
```mermaid
graph TD;
@@ -125,7 +150,7 @@ graph TD;
A[Editor Lite]---Z[Monaco]
```
-Technically, an extension is just an ES6 module that exports a JavaScript object:
+An extension is an ES6 module that exports a JavaScript object:
```javascript
import { Position } from 'monaco-editor';
@@ -138,10 +163,9 @@ export default {
```
-Important things to note here:
-
-- We can depend on other modules in our extensions. This organization helps keep the size of Editor Lite's core at bay by importing dependencies only when needed.
-- `this` in extension's functions refers to the current Editor Lite instance. Using `this`, you get access to the complete instance's API, such as the `setPosition()` method in this particular case.
+In the extension's functions, `this` refers to the current Editor Lite instance.
+Using `this`, you get access to the complete instance's API, such as the
+`setPosition()` method in this particular case.
### Using an existing extension
@@ -159,7 +183,11 @@ editor.use(MyExtension);
### Creating an extension
-Let's create our first Editor Lite extension. Extensions are ES6 modules exporting a basic `Object` that is used to extend Editor Lite's functionality. As a test, let's create an extension that extends Editor Lite with a new function that, when called, outputs editor's content in `alert`.
+Let's create our first Editor Lite extension. Extensions are
+[ES6 modules](https://hacks.mozilla.org/2015/08/es6-in-depth-modules/) exporting a
+basic `Object`, used to extend Editor Lite's features. As a test, let's
+create an extension that extends Editor Lite with a new function that, when called,
+outputs the editor's content in `alert`.
`~/my_folder/my_fancy_extension.js:`
@@ -171,7 +199,10 @@ export default {
};
```
-And that's it with our extension! Note that we're using `this` as a reference to the instance. And through it, we get access to the complete underlying [Monaco editor API](https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.istandalonecodeeditor.html) like `getValue()` in this case.
+In the code example, `this` refers to the instance. By referring to the instance,
+we can access the complete underlying
+[Monaco editor API](https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.istandalonecodeeditor.html),
+which includes functions like `getValue()`.
Now let's use our extension:
@@ -191,7 +222,11 @@ someButton.addEventListener('click', () => {
});
```
-First of all, we import Editor Lite and our new extension. Then we create the editor and its instance. By default Editor Lite has no `throwContentAtMe` method. But the `editor.use(MyFancyExtension)` line brings that method to our instance. After that, we can use it any time we need it. In this case, we call it when some theoretical button has been clicked.
+First of all, we import Editor Lite and our new extension. Then we create the
+editor and its instance. By default Editor Lite has no `throwContentAtMe` method.
+But the `editor.use(MyFancyExtension)` line brings that method to our instance.
+After that, we can use it any time we need it. In this case, we call it when some
+theoretical button has been clicked.
This script would result in an alert containing the editor's content when `someButton` is clicked.
@@ -201,27 +236,28 @@ This script would result in an alert containing the editor's content when `someB
1. Performance
-Just like Editor Lite itself, any extension can be loaded on demand to not harm loading performance of the views:
+ Just like Editor Lite itself, any extension can be loaded on demand to not harm
+ loading performance of the views:
-```javascript
-const EditorPromise = import(
- /* webpackChunkName: 'EditorLite' */ '~/editor/editor_lite'
-);
-const MarkdownExtensionPromise = import('~/editor/editor_markdown_ext');
-
-Promise.all([EditorPromise, MarkdownExtensionPromise])
- .then(([{ default: EditorLite }, { default: MarkdownExtension }]) => {
- const editor = new EditorLite().createInstance({
- ...
- });
- editor.use(MarkdownExtension);
- });
-```
+ ```javascript
+ const EditorPromise = import(
+ /* webpackChunkName: 'EditorLite' */ '~/editor/editor_lite'
+ );
+ const MarkdownExtensionPromise = import('~/editor/editor_markdown_ext');
+
+ Promise.all([EditorPromise, MarkdownExtensionPromise])
+ .then(([{ default: EditorLite }, { default: MarkdownExtension }]) => {
+ const editor = new EditorLite().createInstance({
+ ...
+ });
+ editor.use(MarkdownExtension);
+ });
+ ```
1. Using multiple extensions
-Just pass the array of extensions to your `use` method:
+ Just pass the array of extensions to your `use` method:
-```javascript
-editor.use([FileTemplateExtension, MyFancyExtension]);
-```
+ ```javascript
+ editor.use([FileTemplateExtension, MyFancyExtension]);
+ ```
diff --git a/lib/gitlab/graphql/docs/helper.rb b/lib/gitlab/graphql/docs/helper.rb
index 155602740c4..8690ad2fccf 100644
--- a/lib/gitlab/graphql/docs/helper.rb
+++ b/lib/gitlab/graphql/docs/helper.rb
@@ -111,7 +111,7 @@ module Gitlab
end
def queries
- graphql_operation_types.find { |type| type[:name] == 'Query' }.to_h[:fields]
+ graphql_operation_types.find { |type| type[:name] == 'Query' }.to_h.values_at(:fields, :connections).flatten
end
# We ignore the built-in enum types.
diff --git a/lib/rspec_flaky/config.rb b/lib/rspec_flaky/config.rb
deleted file mode 100644
index 55c1d4747b4..00000000000
--- a/lib/rspec_flaky/config.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-module RspecFlaky
- class Config
- def self.generate_report?
- !!(ENV['FLAKY_RSPEC_GENERATE_REPORT'] =~ /1|true/)
- end
-
- def self.suite_flaky_examples_report_path
- ENV['SUITE_FLAKY_RSPEC_REPORT_PATH'] || Rails.root.join("rspec_flaky/suite-report.json")
- end
-
- def self.flaky_examples_report_path
- ENV['FLAKY_RSPEC_REPORT_PATH'] || Rails.root.join("rspec_flaky/report.json")
- end
-
- def self.new_flaky_examples_report_path
- ENV['NEW_FLAKY_RSPEC_REPORT_PATH'] || Rails.root.join("rspec_flaky/new-report.json")
- end
- end
-end
diff --git a/lib/tasks/gitlab/packages/composer.rake b/lib/tasks/gitlab/packages/composer.rake
new file mode 100644
index 00000000000..c9bccfe9384
--- /dev/null
+++ b/lib/tasks/gitlab/packages/composer.rake
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'logger'
+
+desc "GitLab | Packages | Build composer cache"
+namespace :gitlab do
+ namespace :packages do
+ task build_composer_cache: :environment do
+ logger = Logger.new(STDOUT)
+ logger.info('Starting to build composer cache files')
+
+ ::Packages::Package.composer.find_in_batches do |packages|
+ packages.group_by { |pkg| [pkg.project_id, pkg.name] }.each do |(project_id, name), packages|
+ logger.info("Building cache for #{project_id} -> #{name}")
+ Gitlab::Composer::Cache.new(project: packages.first.project, name: name).execute
+ end
+ end
+ end
+ end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index a26763274e5..dc03e3b54fe 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -11966,6 +11966,9 @@ msgstr ""
msgid "Error uploading file"
msgstr ""
+msgid "Error uploading file. Please try again."
+msgstr ""
+
msgid "Error uploading file: %{stripped}"
msgstr ""
@@ -24762,6 +24765,9 @@ msgstr ""
msgid "Remove due date"
msgstr ""
+msgid "Remove file"
+msgstr ""
+
msgid "Remove fork relationship"
msgstr ""
@@ -28118,6 +28124,9 @@ msgstr ""
msgid "Start a new merge request"
msgstr ""
+msgid "Start a new merge request with these changes"
+msgstr ""
+
msgid "Start a review"
msgstr ""
@@ -32142,31 +32151,31 @@ msgstr ""
msgid "UsageTrends|Projects"
msgstr ""
-msgid "UsageTrends|There was an error fetching the cancelled pipelines"
+msgid "UsageTrends|There was an error fetching the cancelled pipelines. Please try again."
msgstr ""
-msgid "UsageTrends|There was an error fetching the failed pipelines"
+msgid "UsageTrends|There was an error fetching the failed pipelines. Please try again."
msgstr ""
-msgid "UsageTrends|There was an error fetching the issues"
+msgid "UsageTrends|There was an error fetching the groups. Please try again."
msgstr ""
-msgid "UsageTrends|There was an error fetching the merge requests"
+msgid "UsageTrends|There was an error fetching the issues. Please try again."
msgstr ""
-msgid "UsageTrends|There was an error fetching the skipped pipelines"
+msgid "UsageTrends|There was an error fetching the merge requests. Please try again."
msgstr ""
-msgid "UsageTrends|There was an error fetching the successful pipelines"
+msgid "UsageTrends|There was an error fetching the projects. Please try again."
msgstr ""
-msgid "UsageTrends|There was an error fetching the total pipelines"
+msgid "UsageTrends|There was an error fetching the skipped pipelines. Please try again."
msgstr ""
-msgid "UsageTrends|There was an error while loading the groups"
+msgid "UsageTrends|There was an error fetching the successful pipelines. Please try again."
msgstr ""
-msgid "UsageTrends|There was an error while loading the projects"
+msgid "UsageTrends|There was an error fetching the total pipelines. Please try again."
msgstr ""
msgid "UsageTrends|Total groups"
diff --git a/qa/qa/page/component/note.rb b/qa/qa/page/component/note.rb
index 50567796bdb..67583f71bf3 100644
--- a/qa/qa/page/component/note.rb
+++ b/qa/qa/page/component/note.rb
@@ -17,7 +17,6 @@ module QA
element :comment_button
element :comment_field
element :discussion_menu_item
- element :note_dropdown
end
base.view 'app/assets/javascripts/notes/components/discussion_actions.vue' do
@@ -146,7 +145,7 @@ module QA
def start_discussion(text)
fill_element :comment_field, text
- click_element :note_dropdown
+ within_element(:comment_button) { click_button(class: 'dropdown-toggle-split') }
click_element :discussion_menu_item
click_element :comment_button
diff --git a/qa/qa/resource/snippet.rb b/qa/qa/resource/snippet.rb
index 253a3363511..6423dc7a41c 100644
--- a/qa/qa/resource/snippet.rb
+++ b/qa/qa/resource/snippet.rb
@@ -6,6 +6,7 @@ module QA
attr_accessor :title, :description, :file_content, :visibility, :file_name
attribute :id
+ attribute :http_url_to_repo
def initialize
@title = 'New snippet title'
@@ -53,6 +54,10 @@ module QA
'/snippets'
end
+ def api_put_path
+ "/snippets/#{id}"
+ end
+
def api_post_body
{
title: title,
@@ -72,6 +77,28 @@ module QA
file[:file_path] = file.delete(:name)
end
end
+
+ def has_file?(file_path)
+ response = get Runtime::API::Request.new(api_client, api_get_path).url
+
+ raise ResourceNotFoundError, "Request returned (#{response.code}): `#{response}`." if response.code == HTTP_STATUS_NOT_FOUND
+
+ file_output = parse_body(response)[:files]
+ file_output.any? { |file| file[:path] == file_path }
+ end
+
+ def change_repository_storage(new_storage)
+ post_body = { destination_storage_name: new_storage }
+ response = post Runtime::API::Request.new(api_client, "/snippets/#{id}/repository_storage_moves").url, post_body
+
+ unless response.code.between?(200, 300)
+ raise ResourceUpdateFailedError, "Could not change repository storage to #{new_storage}. Request returned (#{response.code}): `#{response}`."
+ end
+
+ wait_until(sleep_interval: 1) { Runtime::API::RepositoryStorageMoves.has_status?(self, 'finished', new_storage) }
+ rescue Support::Repeater::RepeaterConditionExceededError
+ raise Runtime::API::RepositoryStorageMoves::RepositoryStorageMovesError, 'Timed out while waiting for the snippet repository storage move to finish'
+ end
end
end
end
diff --git a/qa/qa/runtime/api/repository_storage_moves.rb b/qa/qa/runtime/api/repository_storage_moves.rb
index d0211d3f66d..5630a9c02c5 100644
--- a/qa/qa/runtime/api/repository_storage_moves.rb
+++ b/qa/qa/runtime/api/repository_storage_moves.rb
@@ -9,9 +9,9 @@ module QA
RepositoryStorageMovesError = Class.new(RuntimeError)
- def has_status?(project, status, destination_storage = Env.additional_repository_storage)
- find_any do |move|
- next unless move[:project][:path_with_namespace] == project.path_with_namespace
+ def has_status?(resource, status, destination_storage = Env.additional_repository_storage)
+ find_any(resource) do |move|
+ next unless resource_equals?(resource, move)
QA::Runtime::Logger.debug("Move data: #{move}")
@@ -20,16 +20,28 @@ module QA
end
end
- def find_any
+ def find_any(resource)
Logger.debug('Getting repository storage moves')
Support::Waiter.wait_until do
- with_paginated_response_body(Request.new(api_client, '/project_repository_storage_moves', per_page: '100').url) do |page|
+ with_paginated_response_body(Request.new(api_client, "/#{resource_name(resource)}_repository_storage_moves", per_page: '100').url) do |page|
break true if page.any? { |item| yield item }
end
end
end
+ def resource_equals?(resource, move)
+ if resource.class.name.include?('Snippet')
+ move[:snippet][:id] == resource.id
+ else
+ move[:project][:path_with_namespace] == resource.path_with_namespace
+ end
+ end
+
+ def resource_name(resource)
+ resource.class.name.split('::').last.downcase
+ end
+
private
def api_client
diff --git a/qa/qa/specs/features/api/3_create/snippet/snippet_repository_storage_move_spec.rb b/qa/qa/specs/features/api/3_create/snippet/snippet_repository_storage_move_spec.rb
new file mode 100644
index 00000000000..4872acd1004
--- /dev/null
+++ b/qa/qa/specs/features/api/3_create/snippet/snippet_repository_storage_move_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module QA
+ RSpec.describe 'Create' do
+ describe 'Snippet repository storage', :requires_admin, :orchestrated, :repository_storage do
+ let(:source_storage) { { type: :gitaly, name: 'default' } }
+ let(:destination_storage) { { type: :gitaly, name: QA::Runtime::Env.additional_repository_storage } }
+
+ let(:snippet) do
+ Resource::Snippet.fabricate_via_api! do |snippet|
+ snippet.title = 'Snippet to move storage of'
+ snippet.file_name = 'original_file'
+ snippet.file_content = 'Original file content'
+ snippet.api_client = Runtime::API::Client.as_admin
+ end
+ end
+
+ praefect_manager = Service::PraefectManager.new
+
+ before do
+ praefect_manager.gitlab = 'gitlab'
+ end
+
+ it 'moves snippet repository from one Gitaly storage to another', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1700' do
+ expect(snippet).to have_file('original_file')
+ expect { snippet.change_repository_storage(destination_storage[:name]) }.not_to raise_error
+ expect { praefect_manager.verify_storage_move(source_storage, destination_storage) }.not_to raise_error
+
+ # verifies you can push commits to the moved snippet
+ Resource::Repository::Push.fabricate! do |push|
+ push.repository_http_uri = snippet.http_url_to_repo
+ push.file_name = 'new_file'
+ push.file_content = 'new file content'
+ push.commit_message = 'Adding a new snippet file'
+ push.new_branch = false
+ end
+
+ aggregate_failures do
+ expect(snippet).to have_file('original_file')
+ expect(snippet).to have_file('new_file')
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/support/api.rb b/qa/qa/support/api.rb
index 5fc36b68e5c..de9da3171b0 100644
--- a/qa/qa/support/api.rb
+++ b/qa/qa/support/api.rb
@@ -9,6 +9,7 @@ module QA
HTTP_STATUS_CREATED = 201
HTTP_STATUS_NO_CONTENT = 204
HTTP_STATUS_ACCEPTED = 202
+ HTTP_STATUS_NOT_FOUND = 404
HTTP_STATUS_SERVER_ERROR = 500
def post(url, payload, args = {})
diff --git a/scripts/flaky_examples/prune-old-flaky-examples b/scripts/flaky_examples/prune-old-flaky-examples
index 8c09c4cc860..a5b50a7e8ea 100755
--- a/scripts/flaky_examples/prune-old-flaky-examples
+++ b/scripts/flaky_examples/prune-old-flaky-examples
@@ -1,16 +1,11 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
-# lib/rspec_flaky/flaky_examples_collection.rb is requiring
+# tooling/rspec_flaky/flaky_examples_collection.rb is requiring
# `active_support/hash_with_indifferent_access`, and we install the `activesupport`
# gem manually on the CI
require 'rubygems'
-
-# In newer Ruby, alias_method is not private then we don't need __send__
-singleton_class.__send__(:alias_method, :require_dependency, :require) # rubocop:disable GitlabSecurity/PublicSend
-$:.unshift(File.expand_path('../../lib', __dir__))
-
-require 'rspec_flaky/report'
+require_relative '../../tooling/rspec_flaky/report'
report_file = ARGV.shift
unless report_file
diff --git a/spec/experiments/application_experiment/cache_spec.rb b/spec/experiments/application_experiment/cache_spec.rb
deleted file mode 100644
index 4caa91e6ac4..00000000000
--- a/spec/experiments/application_experiment/cache_spec.rb
+++ /dev/null
@@ -1,54 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe ApplicationExperiment::Cache do
- let(:key_name) { 'experiment_name' }
- let(:field_name) { 'abc123' }
- let(:key_field) { [key_name, field_name].join(':') }
- let(:shared_state) { Gitlab::Redis::SharedState }
-
- around do |example|
- shared_state.with { |r| r.del(key_name) }
- example.run
- shared_state.with { |r| r.del(key_name) }
- end
-
- it "allows reading, writing and deleting", :aggregate_failures do
- # we test them all together because they are largely interdependent
-
- expect(subject.read(key_field)).to be_nil
- expect(shared_state.with { |r| r.hget(key_name, field_name) }).to be_nil
-
- subject.write(key_field, 'value')
-
- expect(subject.read(key_field)).to eq('value')
- expect(shared_state.with { |r| r.hget(key_name, field_name) }).to eq('value')
-
- subject.delete(key_field)
-
- expect(subject.read(key_field)).to be_nil
- expect(shared_state.with { |r| r.hget(key_name, field_name) }).to be_nil
- end
-
- it "handles the fetch with a block behavior (which is read/write)" do
- expect(subject.fetch(key_field) { 'value1' }).to eq('value1') # rubocop:disable Style/RedundantFetchBlock
- expect(subject.fetch(key_field) { 'value2' }).to eq('value1') # rubocop:disable Style/RedundantFetchBlock
- end
-
- it "can clear a whole experiment cache key" do
- subject.write(key_field, 'value')
- subject.clear(key: key_field)
-
- expect(shared_state.with { |r| r.get(key_name) }).to be_nil
- end
-
- it "doesn't allow clearing a key from the cache that's not a hash (definitely not an experiment)" do
- shared_state.with { |r| r.set(key_name, 'value') }
-
- expect { subject.clear(key: key_name) }.to raise_error(
- ArgumentError,
- 'invalid call to clear a non-hash cache key'
- )
- end
-end
diff --git a/spec/experiments/application_experiment_spec.rb b/spec/experiments/application_experiment_spec.rb
index 501d344e920..3803fa10ab3 100644
--- a/spec/experiments/application_experiment_spec.rb
+++ b/spec/experiments/application_experiment_spec.rb
@@ -191,15 +191,15 @@ RSpec.describe ApplicationExperiment, :experiment do
end
context "when caching" do
- let(:cache) { ApplicationExperiment::Cache.new }
+ let(:cache) { Gitlab::Experiment::Configuration.cache }
before do
+ allow(Gitlab::Experiment::Configuration).to receive(:cache).and_call_original
+
cache.clear(key: subject.name)
subject.use { } # setup the control
subject.try { } # setup the candidate
-
- allow(Gitlab::Experiment::Configuration).to receive(:cache).and_return(cache)
end
it "caches the variant determined by the variant resolver" do
@@ -207,7 +207,7 @@ RSpec.describe ApplicationExperiment, :experiment do
subject.run
- expect(cache.read(subject.cache_key)).to eq('candidate')
+ expect(subject.cache.read).to eq('candidate')
end
it "doesn't cache a variant if we don't explicitly provide one" do
@@ -222,7 +222,7 @@ RSpec.describe ApplicationExperiment, :experiment do
subject.run
- expect(cache.read(subject.cache_key)).to be_nil
+ expect(subject.cache.read).to be_nil
end
it "caches a control variant if we assign it specifically" do
@@ -232,7 +232,26 @@ RSpec.describe ApplicationExperiment, :experiment do
# write code that would specify a different variant.
subject.run(:control)
- expect(cache.read(subject.cache_key)).to eq('control')
+ expect(subject.cache.read).to eq('control')
+ end
+
+ context "arbitrary attributes" do
+ before do
+ subject.cache.store.clear(key: subject.name + '_attrs')
+ end
+
+ it "sets and gets attributes about an experiment" do
+ subject.cache.attr_set(:foo, :bar)
+
+ expect(subject.cache.attr_get(:foo)).to eq('bar')
+ end
+
+ it "increments a value for an experiment" do
+ expect(subject.cache.attr_get(:foo)).to be_nil
+
+ expect(subject.cache.attr_inc(:foo)).to eq(1)
+ expect(subject.cache.attr_inc(:foo)).to eq(2)
+ end
end
end
end
diff --git a/spec/features/discussion_comments/commit_spec.rb b/spec/features/discussion_comments/commit_spec.rb
index 32c0ba2a9a7..261e9fb9f3b 100644
--- a/spec/features/discussion_comments/commit_spec.rb
+++ b/spec/features/discussion_comments/commit_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe 'Thread Comments Commit', :js do
visit project_commit_path(project, sample_commit.id)
end
- it_behaves_like 'thread comments', 'commit'
+ it_behaves_like 'thread comments for commit and snippet', 'commit'
it 'has class .js-note-emoji' do
expect(page).to have_css('.js-note-emoji')
diff --git a/spec/features/discussion_comments/issue_spec.rb b/spec/features/discussion_comments/issue_spec.rb
index 86743e31fbd..6187a13bf96 100644
--- a/spec/features/discussion_comments/issue_spec.rb
+++ b/spec/features/discussion_comments/issue_spec.rb
@@ -16,5 +16,5 @@ RSpec.describe 'Thread Comments Issue', :js do
visit project_issue_path(project, issue)
end
- it_behaves_like 'thread comments', 'issue'
+ it_behaves_like 'thread comments for issue, epic and merge request', 'issue'
end
diff --git a/spec/features/discussion_comments/merge_request_spec.rb b/spec/features/discussion_comments/merge_request_spec.rb
index 82dcdf9f918..a0febe9d9ab 100644
--- a/spec/features/discussion_comments/merge_request_spec.rb
+++ b/spec/features/discussion_comments/merge_request_spec.rb
@@ -20,5 +20,5 @@ RSpec.describe 'Thread Comments Merge Request', :js do
wait_for_requests
end
- it_behaves_like 'thread comments', 'merge request'
+ it_behaves_like 'thread comments for issue, epic and merge request', 'merge request'
end
diff --git a/spec/features/discussion_comments/snippets_spec.rb b/spec/features/discussion_comments/snippets_spec.rb
index 42053e571e9..ca0a6d6e1c5 100644
--- a/spec/features/discussion_comments/snippets_spec.rb
+++ b/spec/features/discussion_comments/snippets_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe 'Thread Comments Snippet', :js do
visit project_snippet_path(project, snippet)
end
- it_behaves_like 'thread comments', 'snippet'
+ it_behaves_like 'thread comments for commit and snippet', 'snippet'
end
context 'with personal snippets' do
@@ -32,6 +32,6 @@ RSpec.describe 'Thread Comments Snippet', :js do
visit snippet_path(snippet)
end
- it_behaves_like 'thread comments', 'snippet'
+ it_behaves_like 'thread comments for commit and snippet', 'snippet'
end
end
diff --git a/spec/features/merge_request/user_posts_notes_spec.rb b/spec/features/merge_request/user_posts_notes_spec.rb
index e629bc0dc53..3099a893dc2 100644
--- a/spec/features/merge_request/user_posts_notes_spec.rb
+++ b/spec/features/merge_request/user_posts_notes_spec.rb
@@ -44,7 +44,10 @@ RSpec.describe 'Merge request > User posts notes', :js do
it 'has enable submit button, preview button and saves content to local storage' do
page.within('.js-main-target-form') do
- expect(page).not_to have_css('.js-comment-button[disabled]')
+ page.within('[data-testid="comment-button"]') do
+ expect(page).to have_css('.split-content-button')
+ expect(page).not_to have_css('.split-content-button[disabled]')
+ end
expect(page).to have_css('.js-md-preview-button', visible: true)
end
diff --git a/spec/features/projects/files/user_uploads_files_spec.rb b/spec/features/projects/files/user_uploads_files_spec.rb
index 944d08df3f3..e8f51837ec9 100644
--- a/spec/features/projects/files/user_uploads_files_spec.rb
+++ b/spec/features/projects/files/user_uploads_files_spec.rb
@@ -3,8 +3,6 @@
require 'spec_helper'
RSpec.describe 'Projects > Files > User uploads files' do
- include DropzoneHelper
-
let(:user) { create(:user) }
let(:project) { create(:project, :repository, name: 'Shop', creator: user) }
let(:project2) { create(:project, :repository, name: 'Another Project', path: 'another-project') }
@@ -17,36 +15,15 @@ RSpec.describe 'Projects > Files > User uploads files' do
context 'when a user has write access' do
before do
visit(project_tree_path(project))
+
+ wait_for_requests
end
include_examples 'it uploads and commit a new text file'
include_examples 'it uploads and commit a new image file'
- it 'uploads a file to a sub-directory', :js do
- click_link 'files'
-
- page.within('.repo-breadcrumb') do
- expect(page).to have_content('files')
- end
-
- find('.add-to-tree').click
- click_link('Upload file')
- drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'))
-
- page.within('#modal-upload-blob') do
- fill_in(:commit_message, with: 'New commit message')
- end
-
- click_button('Upload file')
-
- expect(page).to have_content('New commit message')
-
- page.within('.repo-breadcrumb') do
- expect(page).to have_content('files')
- expect(page).to have_content('doc_sample.txt')
- end
- end
+ include_examples 'it uploads a file to a sub-directory'
end
context 'when a user does not have write access' do
diff --git a/spec/features/projects/show/user_uploads_files_spec.rb b/spec/features/projects/show/user_uploads_files_spec.rb
index b7c5d324d93..5749ffd0650 100644
--- a/spec/features/projects/show/user_uploads_files_spec.rb
+++ b/spec/features/projects/show/user_uploads_files_spec.rb
@@ -17,11 +17,15 @@ RSpec.describe 'Projects > Show > User uploads files' do
context 'when a user has write access' do
before do
visit(project_path(project))
+
+ wait_for_requests
end
include_examples 'it uploads and commit a new text file'
include_examples 'it uploads and commit a new image file'
+
+ include_examples 'it uploads a file to a sub-directory'
end
context 'when a user does not have write access' do
diff --git a/spec/frontend/analytics/usage_trends/components/app_spec.js b/spec/frontend/analytics/usage_trends/components/app_spec.js
index 8f1dd3c445c..f0306ea72e3 100644
--- a/spec/frontend/analytics/usage_trends/components/app_spec.js
+++ b/spec/frontend/analytics/usage_trends/components/app_spec.js
@@ -1,6 +1,5 @@
import { shallowMount } from '@vue/test-utils';
import UsageTrendsApp from '~/analytics/usage_trends/components/app.vue';
-import ProjectsAndGroupsChart from '~/analytics/usage_trends/components/projects_and_groups_chart.vue';
import UsageCounts from '~/analytics/usage_trends/components/usage_counts.vue';
import UsageTrendsCountChart from '~/analytics/usage_trends/components/usage_trends_count_chart.vue';
import UsersChart from '~/analytics/usage_trends/components/users_chart.vue';
@@ -25,7 +24,7 @@ describe('UsageTrendsApp', () => {
expect(wrapper.find(UsageCounts).exists()).toBe(true);
});
- ['Pipelines', 'Issues & Merge Requests'].forEach((usage) => {
+ ['Total projects & groups', 'Pipelines', 'Issues & Merge Requests'].forEach((usage) => {
it(`displays the ${usage} chart`, () => {
const chartTitles = wrapper
.findAll(UsageTrendsCountChart)
@@ -38,8 +37,4 @@ describe('UsageTrendsApp', () => {
it('displays the users chart component', () => {
expect(wrapper.find(UsersChart).exists()).toBe(true);
});
-
- it('displays the projects and groups chart component', () => {
- expect(wrapper.find(ProjectsAndGroupsChart).exists()).toBe(true);
- });
});
diff --git a/spec/frontend/analytics/usage_trends/components/projects_and_groups_chart_spec.js b/spec/frontend/analytics/usage_trends/components/projects_and_groups_chart_spec.js
deleted file mode 100644
index 09215da32e0..00000000000
--- a/spec/frontend/analytics/usage_trends/components/projects_and_groups_chart_spec.js
+++ /dev/null
@@ -1,215 +0,0 @@
-import { GlAlert } from '@gitlab/ui';
-import { GlLineChart } from '@gitlab/ui/dist/charts';
-import { createLocalVue, shallowMount } from '@vue/test-utils';
-import VueApollo from 'vue-apollo';
-import createMockApollo from 'helpers/mock_apollo_helper';
-import ProjectsAndGroupChart from '~/analytics/usage_trends/components/projects_and_groups_chart.vue';
-import groupsQuery from '~/analytics/usage_trends/graphql/queries/groups.query.graphql';
-import projectsQuery from '~/analytics/usage_trends/graphql/queries/projects.query.graphql';
-import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
-import { mockQueryResponse } from '../apollo_mock_data';
-import { mockCountsData2, roundedSortedCountsMonthlyChartData2 } from '../mock_data';
-
-const localVue = createLocalVue();
-localVue.use(VueApollo);
-
-describe('ProjectsAndGroupChart', () => {
- let wrapper;
- let queryResponses = { projects: null, groups: null };
- const mockAdditionalData = [{ recordedAt: '2020-07-21', count: 5 }];
-
- const createComponent = ({
- loadingError = false,
- projects = [],
- groups = [],
- projectsLoading = false,
- groupsLoading = false,
- projectsAdditionalData = [],
- groupsAdditionalData = [],
- } = {}) => {
- queryResponses = {
- projects: mockQueryResponse({
- key: 'projects',
- data: projects,
- loading: projectsLoading,
- additionalData: projectsAdditionalData,
- }),
- groups: mockQueryResponse({
- key: 'groups',
- data: groups,
- loading: groupsLoading,
- additionalData: groupsAdditionalData,
- }),
- };
-
- return shallowMount(ProjectsAndGroupChart, {
- props: {
- startDate: new Date(2020, 9, 26),
- endDate: new Date(2020, 10, 1),
- totalDataPoints: mockCountsData2.length,
- },
- localVue,
- apolloProvider: createMockApollo([
- [projectsQuery, queryResponses.projects],
- [groupsQuery, queryResponses.groups],
- ]),
- data() {
- return { loadingError };
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- queryResponses = {
- projects: null,
- groups: null,
- };
- });
-
- const findLoader = () => wrapper.find(ChartSkeletonLoader);
- const findAlert = () => wrapper.find(GlAlert);
- const findChart = () => wrapper.find(GlLineChart);
-
- describe('while loading', () => {
- beforeEach(() => {
- wrapper = createComponent({ projectsLoading: true, groupsLoading: true });
- });
-
- it('displays the skeleton loader', () => {
- expect(findLoader().exists()).toBe(true);
- });
-
- it('hides the chart', () => {
- expect(findChart().exists()).toBe(false);
- });
- });
-
- describe('while loading 1 data set', () => {
- beforeEach(async () => {
- wrapper = createComponent({
- projects: mockCountsData2,
- groupsLoading: true,
- });
-
- await wrapper.vm.$nextTick();
- });
-
- it('hides the skeleton loader', () => {
- expect(findLoader().exists()).toBe(false);
- });
-
- it('renders the chart', () => {
- expect(findChart().exists()).toBe(true);
- });
- });
-
- describe('without data', () => {
- beforeEach(async () => {
- wrapper = createComponent({ projects: [] });
- await wrapper.vm.$nextTick();
- });
-
- it('renders a no data message', () => {
- expect(findAlert().text()).toBe('No data available.');
- });
-
- it('hides the skeleton loader', () => {
- expect(findLoader().exists()).toBe(false);
- });
-
- it('does not render the chart', () => {
- expect(findChart().exists()).toBe(false);
- });
- });
-
- describe('with data', () => {
- beforeEach(async () => {
- wrapper = createComponent({ projects: mockCountsData2 });
- await wrapper.vm.$nextTick();
- });
-
- it('hides the skeleton loader', () => {
- expect(findLoader().exists()).toBe(false);
- });
-
- it('renders the chart', () => {
- expect(findChart().exists()).toBe(true);
- });
-
- it('passes the data to the line chart', () => {
- expect(findChart().props('data')).toEqual([
- { data: roundedSortedCountsMonthlyChartData2, name: 'Total projects' },
- { data: [], name: 'Total groups' },
- ]);
- });
- });
-
- describe('with errors', () => {
- beforeEach(async () => {
- wrapper = createComponent({ loadingError: true });
- await wrapper.vm.$nextTick();
- });
-
- it('renders an error message', () => {
- expect(findAlert().text()).toBe('No data available.');
- });
-
- it('hides the skeleton loader', () => {
- expect(findLoader().exists()).toBe(false);
- });
-
- it('hides the chart', () => {
- expect(findChart().exists()).toBe(false);
- });
- });
-
- describe.each`
- metric | loadingState | newData
- ${'projects'} | ${{ projectsAdditionalData: mockAdditionalData }} | ${{ projects: mockCountsData2 }}
- ${'groups'} | ${{ groupsAdditionalData: mockAdditionalData }} | ${{ groups: mockCountsData2 }}
- `('$metric - fetchMore', ({ metric, loadingState, newData }) => {
- describe('when the fetchMore query returns data', () => {
- beforeEach(async () => {
- wrapper = createComponent({
- ...loadingState,
- ...newData,
- });
-
- jest.spyOn(wrapper.vm.$apollo.queries[metric], 'fetchMore');
- await wrapper.vm.$nextTick();
- });
-
- it('requests data twice', () => {
- expect(queryResponses[metric]).toBeCalledTimes(2);
- });
-
- it('calls fetchMore', () => {
- expect(wrapper.vm.$apollo.queries[metric].fetchMore).toHaveBeenCalledTimes(1);
- });
- });
-
- describe('when the fetchMore query throws an error', () => {
- beforeEach(() => {
- wrapper = createComponent({
- ...loadingState,
- ...newData,
- });
-
- jest
- .spyOn(wrapper.vm.$apollo.queries[metric], 'fetchMore')
- .mockImplementation(jest.fn().mockRejectedValue());
- return wrapper.vm.$nextTick();
- });
-
- it('calls fetchMore', () => {
- expect(wrapper.vm.$apollo.queries[metric].fetchMore).toHaveBeenCalledTimes(1);
- });
-
- it('renders an error message', () => {
- expect(findAlert().text()).toBe('No data available.');
- });
- });
- });
-});
diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js
index 2f58f75ab70..672eccb026b 100644
--- a/spec/frontend/notes/components/comment_form_spec.js
+++ b/spec/frontend/notes/components/comment_form_spec.js
@@ -1,3 +1,4 @@
+import { GlDropdown } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import Autosize from 'autosize';
import MockAdapter from 'axios-mock-adapter';
@@ -23,9 +24,10 @@ describe('issue_comment_form component', () => {
let axiosMock;
const findCloseReopenButton = () => wrapper.findByTestId('close-reopen-button');
- const findCommentButton = () => wrapper.findByTestId('comment-button');
const findTextArea = () => wrapper.findByTestId('comment-field');
const findConfidentialNoteCheckbox = () => wrapper.findByTestId('confidential-note-checkbox');
+ const findCommentGlDropdown = () => wrapper.find(GlDropdown);
+ const findCommentButton = () => findCommentGlDropdown().find('button');
const createNotableDataMock = (data = {}) => {
return {
@@ -243,7 +245,7 @@ describe('issue_comment_form component', () => {
it('should render comment button as disabled', () => {
mountComponent();
- expect(findCommentButton().props('disabled')).toBe(true);
+ expect(findCommentGlDropdown().props('disabled')).toBe(true);
});
it('should enable comment button if it has note', async () => {
@@ -251,7 +253,7 @@ describe('issue_comment_form component', () => {
await wrapper.setData({ note: 'Foo' });
- expect(findCommentButton().props('disabled')).toBe(false);
+ expect(findCommentGlDropdown().props('disabled')).toBe(false);
});
it('should update buttons texts when it has note', () => {
@@ -437,7 +439,7 @@ describe('issue_comment_form component', () => {
await wrapper.vm.$nextTick();
// submit comment
- wrapper.findByTestId('comment-button').trigger('click');
+ findCommentButton().trigger('click');
const [providedData] = wrapper.vm.saveNote.mock.calls[0];
expect(providedData.data.note.confidential).toBe(shouldCheckboxBeChecked);
diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js
index efee72dea96..163501d5ce8 100644
--- a/spec/frontend/notes/components/notes_app_spec.js
+++ b/spec/frontend/notes/components/notes_app_spec.js
@@ -33,6 +33,8 @@ describe('note_app', () => {
let wrapper;
let store;
+ const findCommentButton = () => wrapper.find('[data-testid="comment-button"]');
+
const getComponentOrder = () => {
return wrapper
.findAll('#notes-list,.js-comment-form')
@@ -144,7 +146,7 @@ describe('note_app', () => {
});
it('should render form comment button as disabled', () => {
- expect(wrapper.find('.js-note-new-discussion').attributes('disabled')).toEqual('disabled');
+ expect(findCommentButton().props('disabled')).toEqual(true);
});
it('updates discussions badge', () => {
diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js
index 2ac2069a177..93bfd3d9d32 100644
--- a/spec/frontend/repository/components/breadcrumbs_spec.js
+++ b/spec/frontend/repository/components/breadcrumbs_spec.js
@@ -1,24 +1,36 @@
import { GlDropdown } from '@gitlab/ui';
import { shallowMount, RouterLinkStub } from '@vue/test-utils';
import Breadcrumbs from '~/repository/components/breadcrumbs.vue';
-
-let vm;
-
-function factory(currentPath, extraProps = {}) {
- vm = shallowMount(Breadcrumbs, {
- propsData: {
- currentPath,
- ...extraProps,
- },
- stubs: {
- RouterLink: RouterLinkStub,
- },
- });
-}
+import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
describe('Repository breadcrumbs component', () => {
+ let wrapper;
+
+ const factory = (currentPath, extraProps = {}) => {
+ const $apollo = {
+ queries: {
+ userPermissions: {
+ loading: true,
+ },
+ },
+ };
+
+ wrapper = shallowMount(Breadcrumbs, {
+ propsData: {
+ currentPath,
+ ...extraProps,
+ },
+ stubs: {
+ RouterLink: RouterLinkStub,
+ },
+ mocks: { $apollo },
+ });
+ };
+
+ const findUploadBlobModal = () => wrapper.find(UploadBlobModal);
+
afterEach(() => {
- vm.destroy();
+ wrapper.destroy();
});
it.each`
@@ -30,13 +42,13 @@ describe('Repository breadcrumbs component', () => {
`('renders $linkCount links for path $path', ({ path, linkCount }) => {
factory(path);
- expect(vm.findAll(RouterLinkStub).length).toEqual(linkCount);
+ expect(wrapper.findAll(RouterLinkStub).length).toEqual(linkCount);
});
it('escapes hash in directory path', () => {
factory('app/assets/javascripts#');
- expect(vm.findAll(RouterLinkStub).at(3).props('to')).toEqual(
+ expect(wrapper.findAll(RouterLinkStub).at(3).props('to')).toEqual(
'/-/tree/app/assets/javascripts%23',
);
});
@@ -44,26 +56,44 @@ describe('Repository breadcrumbs component', () => {
it('renders last link as active', () => {
factory('app/assets');
- expect(vm.findAll(RouterLinkStub).at(2).attributes('aria-current')).toEqual('page');
+ expect(wrapper.findAll(RouterLinkStub).at(2).attributes('aria-current')).toEqual('page');
});
- it('does not render add to tree dropdown when permissions are false', () => {
+ it('does not render add to tree dropdown when permissions are false', async () => {
factory('/', { canCollaborate: false });
- vm.setData({ userPermissions: { forkProject: false, createMergeRequestIn: false } });
+ wrapper.setData({ userPermissions: { forkProject: false, createMergeRequestIn: false } });
- return vm.vm.$nextTick(() => {
- expect(vm.find(GlDropdown).exists()).toBe(false);
- });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(GlDropdown).exists()).toBe(false);
});
- it('renders add to tree dropdown when permissions are true', () => {
+ it('renders add to tree dropdown when permissions are true', async () => {
factory('/', { canCollaborate: true });
- vm.setData({ userPermissions: { forkProject: true, createMergeRequestIn: true } });
+ wrapper.setData({ userPermissions: { forkProject: true, createMergeRequestIn: true } });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(GlDropdown).exists()).toBe(true);
+ });
+
+ describe('renders the upload blob modal', () => {
+ beforeEach(() => {
+ factory('/', { canEditTree: true });
+ });
+
+ it('does not render the modal while loading', () => {
+ expect(findUploadBlobModal().exists()).toBe(false);
+ });
+
+ it('renders the modal once loaded', async () => {
+ wrapper.setData({ $apollo: { queries: { userPermissions: { loading: false } } } });
+
+ await wrapper.vm.$nextTick();
- return vm.vm.$nextTick(() => {
- expect(vm.find(GlDropdown).exists()).toBe(true);
+ expect(findUploadBlobModal().exists()).toBe(true);
});
});
});
diff --git a/spec/frontend/repository/components/upload_blob_modal_spec.js b/spec/frontend/repository/components/upload_blob_modal_spec.js
new file mode 100644
index 00000000000..6e3cbad558d
--- /dev/null
+++ b/spec/frontend/repository/components/upload_blob_modal_spec.js
@@ -0,0 +1,193 @@
+import { GlModal, GlFormInput, GlFormTextarea, GlToggle, GlAlert } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
+import httpStatusCodes from '~/lib/utils/http_status';
+import { visitUrl } from '~/lib/utils/url_utility';
+import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
+import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
+
+jest.mock('~/flash');
+jest.mock('~/lib/utils/url_utility', () => ({
+ visitUrl: jest.fn(),
+ joinPaths: () => '/new_upload',
+}));
+
+const initialProps = {
+ modalId: 'upload-blob',
+ commitMessage: 'Upload New File',
+ targetBranch: 'master',
+ origionalBranch: 'master',
+ canPushCode: true,
+ path: 'new_upload',
+};
+
+describe('UploadBlobModal', () => {
+ let wrapper;
+ let mock;
+
+ const mockEvent = { preventDefault: jest.fn() };
+
+ const createComponent = (props) => {
+ wrapper = shallowMount(UploadBlobModal, {
+ propsData: {
+ ...initialProps,
+ ...props,
+ },
+ mocks: {
+ $route: {
+ params: {
+ path: '',
+ },
+ },
+ },
+ });
+ };
+
+ const findModal = () => wrapper.find(GlModal);
+ const findAlert = () => wrapper.find(GlAlert);
+ const findCommitMessage = () => wrapper.find(GlFormTextarea);
+ const findBranchName = () => wrapper.find(GlFormInput);
+ const findMrToggle = () => wrapper.find(GlToggle);
+ const findUploadDropzone = () => wrapper.find(UploadDropzone);
+ const actionButtonDisabledState = () => findModal().props('actionPrimary').attributes[0].disabled;
+ const cancelButtonDisabledState = () => findModal().props('actionCancel').attributes[0].disabled;
+ const actionButtonLoadingState = () => findModal().props('actionPrimary').attributes[0].loading;
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe.each`
+ canPushCode | displayBranchName | displayForkedBranchMessage
+ ${true} | ${true} | ${false}
+ ${false} | ${false} | ${true}
+ `(
+ 'canPushCode = $canPushCode',
+ ({ canPushCode, displayBranchName, displayForkedBranchMessage }) => {
+ beforeEach(() => {
+ createComponent({ canPushCode });
+ });
+
+ it('displays the modal', () => {
+ expect(findModal().exists()).toBe(true);
+ });
+
+ it('includes the upload dropzone', () => {
+ expect(findUploadDropzone().exists()).toBe(true);
+ });
+
+ it('includes the commit message', () => {
+ expect(findCommitMessage().exists()).toBe(true);
+ });
+
+ it('displays the disabled upload button', () => {
+ expect(actionButtonDisabledState()).toBe(true);
+ });
+
+ it('displays the enabled cancel button', () => {
+ expect(cancelButtonDisabledState()).toBe(false);
+ });
+
+ it('does not display the MR toggle', () => {
+ expect(findMrToggle().exists()).toBe(false);
+ });
+
+ it(`${
+ displayForkedBranchMessage ? 'displays' : 'does not display'
+ } the forked branch message`, () => {
+ expect(findAlert().exists()).toBe(displayForkedBranchMessage);
+ });
+
+ it(`${displayBranchName ? 'displays' : 'does not display'} the branch name`, () => {
+ expect(findBranchName().exists()).toBe(displayBranchName);
+ });
+
+ if (canPushCode) {
+ describe('when changing the branch name', () => {
+ it('displays the MR toggle', async () => {
+ wrapper.setData({ target: 'Not master' });
+
+ await wrapper.vm.$nextTick();
+
+ expect(findMrToggle().exists()).toBe(true);
+ });
+ });
+ }
+
+ describe('completed form', () => {
+ beforeEach(() => {
+ wrapper.setData({
+ file: { type: 'jpg' },
+ filePreviewURL: 'http://file.com?format=jpg',
+ });
+ });
+
+ it('enables the upload button when the form is completed', () => {
+ expect(actionButtonDisabledState()).toBe(false);
+ });
+
+ describe('form submission', () => {
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+
+ findModal().vm.$emit('primary', mockEvent);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('disables the upload button', () => {
+ expect(actionButtonDisabledState()).toBe(true);
+ });
+
+ it('sets the upload button to loading', () => {
+ expect(actionButtonLoadingState()).toBe(true);
+ });
+ });
+
+ describe('successful response', () => {
+ beforeEach(async () => {
+ mock = new MockAdapter(axios);
+ mock.onPost(initialProps.path).reply(httpStatusCodes.OK, { filePath: 'blah' });
+
+ findModal().vm.$emit('primary', mockEvent);
+
+ await waitForPromises();
+ });
+
+ it('redirects to the uploaded file', () => {
+ expect(visitUrl).toHaveBeenCalled();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+ });
+
+ describe('error response', () => {
+ beforeEach(async () => {
+ mock = new MockAdapter(axios);
+ mock.onPost(initialProps.path).timeout();
+
+ findModal().vm.$emit('primary', mockEvent);
+
+ await waitForPromises();
+ });
+
+ it('creates a flash error', () => {
+ expect(createFlash).toHaveBeenCalledWith('Error uploading file. Please try again.');
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+ });
+ });
+ },
+ );
+});
diff --git a/spec/graphql/types/merge_request_type_spec.rb b/spec/graphql/types/merge_request_type_spec.rb
index 63d288934e5..3314ea62324 100644
--- a/spec/graphql/types/merge_request_type_spec.rb
+++ b/spec/graphql/types/merge_request_type_spec.rb
@@ -23,7 +23,7 @@ RSpec.describe GitlabSchema.types['MergeRequest'] do
merge_error allow_collaboration should_be_rebased rebase_commit_sha
rebase_in_progress default_merge_commit_message
merge_ongoing mergeable_discussions_state web_url
- source_branch_exists target_branch_exists
+ source_branch_exists target_branch_exists diverged_from_target_branch
upvotes downvotes head_pipeline pipelines task_completion_status
milestone assignees reviewers participants subscribed labels discussion_locked time_estimate
total_time_spent reference author merged_at commit_count current_user_todos
@@ -77,4 +77,33 @@ RSpec.describe GitlabSchema.types['MergeRequest'] do
end
end
end
+
+ describe '#diverged_from_target_branch' do
+ subject(:execute_query) { GitlabSchema.execute(query, context: { current_user: current_user }).as_json }
+
+ let!(:merge_request) { create(:merge_request, target_project: project, source_project: project) }
+ let(:project) { create(:project, :public) }
+ let(:current_user) { create :admin }
+ let(:query) do
+ %(
+ {
+ project(fullPath: "#{project.full_path}") {
+ mergeRequests {
+ nodes {
+ divergedFromTargetBranch
+ }
+ }
+ }
+ }
+ )
+ end
+
+ it 'delegates the diverged_from_target_branch? call to the merge request entity' do
+ expect_next_found_instance_of(MergeRequest) do |instance|
+ expect(instance).to receive(:diverged_from_target_branch?)
+ end
+
+ execute_query
+ end
+ end
end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 2c745a0a70c..ddda3ede083 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -180,6 +180,8 @@ RSpec.configure do |config|
end
if ENV['FLAKY_RSPEC_GENERATE_REPORT']
+ require_relative '../tooling/rspec_flaky/listener'
+
config.reporter.register_listener(
RspecFlaky::Listener.new,
:example_passed,
diff --git a/spec/support/gitlab_experiment.rb b/spec/support/gitlab_experiment.rb
index 45ae9958c52..56fcb8330e8 100644
--- a/spec/support/gitlab_experiment.rb
+++ b/spec/support/gitlab_experiment.rb
@@ -12,5 +12,9 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp
end
end
-# Disable all caching for experiments in tests.
-Gitlab::Experiment::Configuration.cache = nil
+RSpec.configure do |config|
+ # Disable all caching for experiments in tests.
+ config.before do
+ allow(Gitlab::Experiment::Configuration).to receive(:cache).and_return(nil)
+ end
+end
diff --git a/spec/support/shared_examples/features/discussion_comments_shared_example.rb b/spec/support/shared_examples/features/discussion_comments_shared_example.rb
index 6bebd59ed70..86ba2821c78 100644
--- a/spec/support/shared_examples/features/discussion_comments_shared_example.rb
+++ b/spec/support/shared_examples/features/discussion_comments_shared_example.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-RSpec.shared_examples 'thread comments' do |resource_name|
+RSpec.shared_examples 'thread comments for commit and snippet' do |resource_name|
let(:form_selector) { '.js-main-target-form' }
let(:dropdown_selector) { "#{form_selector} .comment-type-dropdown" }
let(:toggle_selector) { "#{dropdown_selector} .dropdown-toggle" }
@@ -24,23 +24,6 @@ RSpec.shared_examples 'thread comments' do |resource_name|
expect(new_comment).not_to have_selector '.discussion'
end
- if resource_name == 'issue'
- it "clicking 'Comment & close #{resource_name}' will post a comment and close the #{resource_name}" do
- find("#{form_selector} .note-textarea").send_keys(comment)
-
- click_button 'Comment & close issue'
-
- wait_for_all_requests
-
- expect(page).to have_content(comment)
- expect(page).to have_content "@#{user.username} closed"
-
- new_comment = all(comments_selector).last
-
- expect(new_comment).not_to have_selector '.discussion'
- end
- end
-
describe 'when the toggle is clicked' do
before do
find("#{form_selector} .note-textarea").send_keys(comment)
@@ -110,33 +93,172 @@ RSpec.shared_examples 'thread comments' do |resource_name|
end
it 'updates the submit button text and closes the dropdown' do
- button = find(submit_selector)
+ expect(find(submit_selector).value).to eq 'Start thread'
- # on issues page, the submit input is a <button>, on other pages it is <input>
- if button.tag_name == 'button'
- expect(find(submit_selector)).to have_content 'Start thread'
- else
- expect(find(submit_selector).value).to eq 'Start thread'
+ expect(page).not_to have_selector menu_selector
+ end
+
+ describe 'creating a thread' do
+ before do
+ find(submit_selector).click
+ wait_for_requests
+
+ find(comments_selector, match: :first)
end
- expect(page).not_to have_selector menu_selector
+ def submit_reply(text)
+ find("#{comments_selector} .js-vue-discussion-reply").click
+ find("#{comments_selector} .note-textarea").send_keys(text)
+
+ find("#{comments_selector} .js-comment-button").click
+ wait_for_requests
+ end
+
+ it 'clicking "Start thread" will post a thread' do
+ expect(page).to have_content(comment)
+
+ new_comment = all(comments_selector).last
+
+ expect(new_comment).to have_selector('.discussion')
+ end
end
- if resource_name =~ /(issue|merge request)/
- it 'updates the close button text' do
- expect(find(close_selector)).to have_content "Start thread & close #{resource_name}"
+ describe 'when opening the menu' do
+ before do
+ find(toggle_selector).click
+ end
+
+ it 'has "Start thread" selected' do
+ find("#{menu_selector} li", match: :first)
+ items = all("#{menu_selector} li")
+
+ expect(items.first).to have_content 'Comment'
+ expect(items.first).not_to have_selector '[data-testid="check-icon"]'
+ expect(items.first['class']).not_to match 'droplab-item-selected'
+
+ expect(items.last).to have_content 'Start thread'
+ expect(items.last).to have_selector '[data-testid="check-icon"]'
+ expect(items.last['class']).to match 'droplab-item-selected'
end
- it 'typing does not change the close button text' do
- find("#{form_selector} .note-textarea").send_keys('b')
+ describe 'when selecting "Comment"' do
+ before do
+ find("#{menu_selector} li", match: :first).click
+ end
+
+ it 'updates the submit button text and closes the dropdown' do
+ button = find(submit_selector)
+
+ expect(button.value).to eq 'Comment'
+
+ expect(page).not_to have_selector menu_selector
+ end
- expect(find(close_selector)).to have_content "Start thread & close #{resource_name}"
+ it 'has "Comment" selected when opening the menu', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/196825' do
+ find(toggle_selector).click
+
+ find("#{menu_selector} li", match: :first)
+ items = all("#{menu_selector} li")
+
+ aggregate_failures do
+ expect(items.first).to have_content 'Comment'
+ expect(items.first).to have_selector '[data-testid="check-icon"]'
+ expect(items.first['class']).to match 'droplab-item-selected'
+
+ expect(items.last).to have_content 'Start thread'
+ expect(items.last).not_to have_selector '[data-testid="check-icon"]'
+ expect(items.last['class']).not_to match 'droplab-item-selected'
+ end
+ end
end
end
+ end
+ end
+end
+
+RSpec.shared_examples 'thread comments for issue, epic and merge request' do |resource_name|
+ let(:form_selector) { '.js-main-target-form' }
+ let(:dropdown_selector) { "#{form_selector} [data-testid='comment-button']" }
+ let(:submit_button_selector) { "#{dropdown_selector} .split-content-button" }
+ let(:toggle_selector) { "#{dropdown_selector} .dropdown-toggle-split" }
+ let(:menu_selector) { "#{dropdown_selector} .dropdown-menu" }
+ let(:close_selector) { "#{form_selector} .btn-comment-and-close" }
+ let(:comments_selector) { '.timeline > .note.timeline-entry' }
+ let(:comment) { 'My comment' }
+
+ it 'clicking "Comment" will post a comment' do
+ expect(page).to have_selector toggle_selector
+
+ find("#{form_selector} .note-textarea").send_keys(comment)
+
+ find(submit_button_selector).click
+
+ expect(page).to have_content(comment)
+
+ new_comment = all(comments_selector).last
+
+ expect(new_comment).not_to have_selector '.discussion'
+ end
+
+ if resource_name == 'issue'
+ it "clicking 'Comment & close #{resource_name}' will post a comment and close the #{resource_name}" do
+ find("#{form_selector} .note-textarea").send_keys(comment)
+
+ click_button 'Comment & close issue'
+
+ wait_for_all_requests
+
+ expect(page).to have_content(comment)
+ expect(page).to have_content "@#{user.username} closed"
+
+ new_comment = all(comments_selector).last
+
+ expect(new_comment).not_to have_selector '.discussion'
+ end
+ end
+
+ describe 'when the toggle is clicked' do
+ before do
+ find("#{form_selector} .note-textarea").send_keys(comment)
+
+ find(toggle_selector).click
+ end
+
+ it 'has a "Comment" item (selected by default) and "Start thread" item' do
+ expect(page).to have_selector menu_selector
+
+ find("#{menu_selector} li", match: :first)
+ items = all("#{menu_selector} li")
+
+ expect(page).to have_selector("#{dropdown_selector}[data-track-label='comment_button']")
+
+ expect(items.first).to have_content 'Comment'
+ expect(items.first).to have_content "Add a general comment to this #{resource_name}."
+
+ expect(items.last).to have_content 'Start thread'
+ expect(items.last).to have_content "Discuss a specific suggestion or question#{' that needs to be resolved' if resource_name == 'merge request'}."
+ end
+
+ it 'closes the menu when clicking the toggle or body' do
+ find(toggle_selector).click
+
+ expect(page).not_to have_selector menu_selector
+
+ find(toggle_selector).click
+ find("#{form_selector} .note-textarea").click
+
+ expect(page).not_to have_selector menu_selector
+ end
+
+ describe 'when selecting "Start thread"' do
+ before do
+ find("#{menu_selector} li", match: :first)
+ all("#{menu_selector} li").last.click
+ end
describe 'creating a thread' do
before do
- find(submit_selector).click
+ find(submit_button_selector).click
wait_for_requests
find(comments_selector, match: :first)
@@ -146,6 +268,7 @@ RSpec.shared_examples 'thread comments' do |resource_name|
find("#{comments_selector} .js-vue-discussion-reply").click
find("#{comments_selector} .note-textarea").send_keys(text)
+ # .js-comment-button here refers to the reply button in note_form.vue
find("#{comments_selector} .js-comment-button").click
wait_for_requests
end
@@ -228,13 +351,11 @@ RSpec.shared_examples 'thread comments' do |resource_name|
find("#{menu_selector} li", match: :first)
items = all("#{menu_selector} li")
+ expect(page).to have_selector("#{dropdown_selector}[data-track-label='start_thread_button']")
+
expect(items.first).to have_content 'Comment'
- expect(items.first).not_to have_selector '[data-testid="check-icon"]'
- expect(items.first['class']).not_to match 'droplab-item-selected'
expect(items.last).to have_content 'Start thread'
- expect(items.last).to have_selector '[data-testid="check-icon"]'
- expect(items.last['class']).to match 'droplab-item-selected'
end
describe 'when selecting "Comment"' do
@@ -243,14 +364,9 @@ RSpec.shared_examples 'thread comments' do |resource_name|
end
it 'updates the submit button text and closes the dropdown' do
- button = find(submit_selector)
+ button = find(submit_button_selector)
- # on issues page, the submit input is a <button>, on other pages it is <input>
- if button.tag_name == 'button'
- expect(button).to have_content 'Comment'
- else
- expect(button.value).to eq 'Comment'
- end
+ expect(button).to have_content 'Comment'
expect(page).not_to have_selector menu_selector
end
@@ -267,21 +383,17 @@ RSpec.shared_examples 'thread comments' do |resource_name|
end
end
- it 'has "Comment" selected when opening the menu', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/196825' do
+ it 'has "Comment" selected when opening the menu' do
find(toggle_selector).click
find("#{menu_selector} li", match: :first)
items = all("#{menu_selector} li")
- aggregate_failures do
- expect(items.first).to have_content 'Comment'
- expect(items.first).to have_selector '[data-testid="check-icon"]'
- expect(items.first['class']).to match 'droplab-item-selected'
+ expect(page).to have_selector("#{dropdown_selector}[data-track-label='comment_button']")
- expect(items.last).to have_content 'Start thread'
- expect(items.last).not_to have_selector '[data-testid="check-icon"]'
- expect(items.last['class']).not_to match 'droplab-item-selected'
- end
+ expect(items.first).to have_content 'Comment'
+
+ expect(items.last).to have_content 'Start thread'
end
end
end
diff --git a/spec/support/shared_examples/features/project_upload_files_shared_examples.rb b/spec/support/shared_examples/features/project_upload_files_shared_examples.rb
index 4411c91d479..72033ecf187 100644
--- a/spec/support/shared_examples/features/project_upload_files_shared_examples.rb
+++ b/spec/support/shared_examples/features/project_upload_files_shared_examples.rb
@@ -10,7 +10,7 @@ RSpec.shared_examples 'it uploads and commit a new text file' do
wait_for_requests
end
- drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'))
+ attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true)
page.within('#modal-upload-blob') do
fill_in(:commit_message, with: 'New commit message')
@@ -42,7 +42,7 @@ RSpec.shared_examples 'it uploads and commit a new image file' do
wait_for_requests
end
- drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'logo_sample.svg'))
+ attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'logo_sample.svg'), make_visible: true)
page.within('#modal-upload-blob') do
fill_in(:commit_message, with: 'New commit message')
@@ -70,9 +70,11 @@ RSpec.shared_examples 'it uploads and commit a new file to a forked project' do
expect(page).to have_content(fork_message)
+ wait_for_all_requests
+
find('.add-to-tree').click
click_link('Upload file')
- drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'))
+ attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true)
page.within('#modal-upload-blob') do
fill_in(:commit_message, with: 'New commit message')
@@ -94,3 +96,30 @@ RSpec.shared_examples 'it uploads and commit a new file to a forked project' do
expect(page).to have_content('Sed ut perspiciatis unde omnis')
end
end
+
+RSpec.shared_examples 'it uploads a file to a sub-directory' do
+ it 'uploads a file to a sub-directory', :js do
+ click_link 'files'
+
+ page.within('.repo-breadcrumb') do
+ expect(page).to have_content('files')
+ end
+
+ find('.add-to-tree').click
+ click_link('Upload file')
+ attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true)
+
+ page.within('#modal-upload-blob') do
+ fill_in(:commit_message, with: 'New commit message')
+ end
+
+ click_button('Upload file')
+
+ expect(page).to have_content('New commit message')
+
+ page.within('.repo-breadcrumb') do
+ expect(page).to have_content('files')
+ expect(page).to have_content('doc_sample.txt')
+ end
+ end
+end
diff --git a/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb b/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb
index 17fd2b836d3..92849ddf1cb 100644
--- a/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb
+++ b/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb
@@ -93,6 +93,6 @@ end
def submit_time(quick_action)
fill_in 'note[note]', with: quick_action
- find('.js-comment-submit-button').click
+ find('[data-testid="comment-button"]').click
wait_for_requests
end
diff --git a/spec/tasks/gitlab/packages/composer_rake_spec.rb b/spec/tasks/gitlab/packages/composer_rake_spec.rb
new file mode 100644
index 00000000000..d54e1b02599
--- /dev/null
+++ b/spec/tasks/gitlab/packages/composer_rake_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'rake_helper'
+
+RSpec.describe 'gitlab:packages:build_composer_cache namespace rake task' do
+ let_it_be(:package_name) { 'sample-project' }
+ let_it_be(:package_name2) { 'sample-project2' }
+ let_it_be(:json) { { 'name' => package_name } }
+ let_it_be(:json2) { { 'name' => package_name2 } }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, :custom_repo, files: { 'composer.json' => json.to_json }, group: group) }
+ let_it_be(:project2) { create(:project, :custom_repo, files: { 'composer.json' => json2.to_json }, group: group) }
+ let!(:package) { create(:composer_package, :with_metadatum, project: project, name: package_name, version: '1.0.0', json: json) }
+ let!(:package2) { create(:composer_package, :with_metadatum, project: project, name: package_name, version: '2.0.0', json: json) }
+ let!(:package3) { create(:composer_package, :with_metadatum, project: project2, name: package_name2, version: '3.0.0', json: json2) }
+
+ before :all do
+ Rake.application.rake_require 'tasks/gitlab/packages/composer'
+ end
+
+ subject do
+ run_rake_task("gitlab:packages:build_composer_cache")
+ end
+
+ it 'generates the cache files' do
+ expect { subject }.to change { Packages::Composer::CacheFile.count }.by(2)
+ end
+end
diff --git a/spec/lib/rspec_flaky/config_spec.rb b/spec/tooling/rspec_flaky/config_spec.rb
index 6b148599b67..12b5ed74cb2 100644
--- a/spec/lib/rspec_flaky/config_spec.rb
+++ b/spec/tooling/rspec_flaky/config_spec.rb
@@ -1,14 +1,23 @@
# frozen_string_literal: true
-require 'spec_helper'
+require 'rspec-parameterized'
+require_relative '../../support/helpers/stub_env'
+
+require_relative '../../../tooling/rspec_flaky/config'
RSpec.describe RspecFlaky::Config, :aggregate_failures do
+ include StubENV
+
before do
# Stub these env variables otherwise specs don't behave the same on the CI
stub_env('FLAKY_RSPEC_GENERATE_REPORT', nil)
stub_env('SUITE_FLAKY_RSPEC_REPORT_PATH', nil)
stub_env('FLAKY_RSPEC_REPORT_PATH', nil)
stub_env('NEW_FLAKY_RSPEC_REPORT_PATH', nil)
+ # Ensure the behavior is the same locally and on CI (where Rails is defined since we run this test as part of the whole suite), i.e. Rails isn't defined
+ allow(described_class).to receive(:rails_path).and_wrap_original do |method, path|
+ path
+ end
end
describe '.generate_report?' do
@@ -44,10 +53,7 @@ RSpec.describe RspecFlaky::Config, :aggregate_failures do
describe '.suite_flaky_examples_report_path' do
context "when ENV['SUITE_FLAKY_RSPEC_REPORT_PATH'] is not set" do
it 'returns the default path' do
- expect(Rails.root).to receive(:join).with('rspec_flaky/suite-report.json')
- .and_return('root/rspec_flaky/suite-report.json')
-
- expect(described_class.suite_flaky_examples_report_path).to eq('root/rspec_flaky/suite-report.json')
+ expect(described_class.suite_flaky_examples_report_path).to eq('rspec_flaky/suite-report.json')
end
end
@@ -65,10 +71,7 @@ RSpec.describe RspecFlaky::Config, :aggregate_failures do
describe '.flaky_examples_report_path' do
context "when ENV['FLAKY_RSPEC_REPORT_PATH'] is not set" do
it 'returns the default path' do
- expect(Rails.root).to receive(:join).with('rspec_flaky/report.json')
- .and_return('root/rspec_flaky/report.json')
-
- expect(described_class.flaky_examples_report_path).to eq('root/rspec_flaky/report.json')
+ expect(described_class.flaky_examples_report_path).to eq('rspec_flaky/report.json')
end
end
@@ -86,10 +89,7 @@ RSpec.describe RspecFlaky::Config, :aggregate_failures do
describe '.new_flaky_examples_report_path' do
context "when ENV['NEW_FLAKY_RSPEC_REPORT_PATH'] is not set" do
it 'returns the default path' do
- expect(Rails.root).to receive(:join).with('rspec_flaky/new-report.json')
- .and_return('root/rspec_flaky/new-report.json')
-
- expect(described_class.new_flaky_examples_report_path).to eq('root/rspec_flaky/new-report.json')
+ expect(described_class.new_flaky_examples_report_path).to eq('rspec_flaky/new-report.json')
end
end
diff --git a/spec/lib/rspec_flaky/example_spec.rb b/spec/tooling/rspec_flaky/example_spec.rb
index 4b45a15c463..8ff280fd855 100644
--- a/spec/lib/rspec_flaky/example_spec.rb
+++ b/spec/tooling/rspec_flaky/example_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-require 'spec_helper'
+require_relative '../../../tooling/rspec_flaky/example'
RSpec.describe RspecFlaky::Example do
let(:example_attrs) do
diff --git a/spec/lib/rspec_flaky/flaky_example_spec.rb b/spec/tooling/rspec_flaky/flaky_example_spec.rb
index b1647d5830a..ab652662c0b 100644
--- a/spec/lib/rspec_flaky/flaky_example_spec.rb
+++ b/spec/tooling/rspec_flaky/flaky_example_spec.rb
@@ -1,8 +1,14 @@
# frozen_string_literal: true
-require 'spec_helper'
+require 'active_support/testing/time_helpers'
+require_relative '../../support/helpers/stub_env'
+
+require_relative '../../../tooling/rspec_flaky/flaky_example'
RSpec.describe RspecFlaky::FlakyExample, :aggregate_failures do
+ include ActiveSupport::Testing::TimeHelpers
+ include StubENV
+
let(:flaky_example_attrs) do
{
example_id: 'spec/foo/bar_spec.rb:2',
@@ -30,7 +36,7 @@ RSpec.describe RspecFlaky::FlakyExample, :aggregate_failures do
}
end
- let(:example) { double(example_attrs) }
+ let(:example) { OpenStruct.new(example_attrs) }
before do
# Stub these env variables otherwise specs don't behave the same on the CI
@@ -77,19 +83,33 @@ RSpec.describe RspecFlaky::FlakyExample, :aggregate_failures do
shared_examples 'an up-to-date FlakyExample instance' do
let(:flaky_example) { described_class.new(args) }
- it 'updates the first_flaky_at' do
- now = Time.now
- expected_first_flaky_at = flaky_example.first_flaky_at || now
- Timecop.freeze(now) { flaky_example.update_flakiness! }
+ it 'sets the first_flaky_at if none exists' do
+ args[:first_flaky_at] = nil
- expect(flaky_example.first_flaky_at).to eq(expected_first_flaky_at)
+ freeze_time do
+ flaky_example.update_flakiness!
+
+ expect(flaky_example.first_flaky_at).to eq(Time.now)
+ end
+ end
+
+ it 'maintains the first_flaky_at if exists' do
+ flaky_example.update_flakiness!
+ expected_first_flaky_at = flaky_example.first_flaky_at
+
+ travel_to(Time.now + 42) do
+ flaky_example.update_flakiness!
+ expect(flaky_example.first_flaky_at).to eq(expected_first_flaky_at)
+ end
end
it 'updates the last_flaky_at' do
- now = Time.now
- Timecop.freeze(now) { flaky_example.update_flakiness! }
+ travel_to(Time.now + 42) do
+ the_future = Time.now
+ flaky_example.update_flakiness!
- expect(flaky_example.last_flaky_at).to eq(now)
+ expect(flaky_example.last_flaky_at).to eq(the_future)
+ end
end
it 'updates the flaky_reports' do
diff --git a/spec/lib/rspec_flaky/flaky_examples_collection_spec.rb b/spec/tooling/rspec_flaky/flaky_examples_collection_spec.rb
index b2fd1d3733a..823459e31b4 100644
--- a/spec/lib/rspec_flaky/flaky_examples_collection_spec.rb
+++ b/spec/tooling/rspec_flaky/flaky_examples_collection_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-require 'spec_helper'
+require_relative '../../../tooling/rspec_flaky/flaky_examples_collection'
RSpec.describe RspecFlaky::FlakyExamplesCollection, :aggregate_failures do
let(:collection_hash) do
diff --git a/spec/lib/rspec_flaky/listener_spec.rb b/spec/tooling/rspec_flaky/listener_spec.rb
index 10ed724d4de..429724a20cf 100644
--- a/spec/lib/rspec_flaky/listener_spec.rb
+++ b/spec/tooling/rspec_flaky/listener_spec.rb
@@ -1,8 +1,14 @@
# frozen_string_literal: true
-require 'spec_helper'
+require 'active_support/testing/time_helpers'
+require_relative '../../support/helpers/stub_env'
+
+require_relative '../../../tooling/rspec_flaky/listener'
RSpec.describe RspecFlaky::Listener, :aggregate_failures do
+ include ActiveSupport::Testing::TimeHelpers
+ include StubENV
+
let(:already_flaky_example_uid) { '6e869794f4cfd2badd93eb68719371d1' }
let(:suite_flaky_example_report) do
{
@@ -130,14 +136,13 @@ RSpec.describe RspecFlaky::Listener, :aggregate_failures do
it 'changes the flaky examples hash' do
new_example = RspecFlaky::Example.new(rspec_example)
- now = Time.now
- Timecop.freeze(now) do
+ travel_to(Time.now + 42) do
+ the_future = Time.now
expect { listener.example_passed(notification) }
.to change { listener.flaky_examples[new_example.uid].to_h }
+ expect(listener.flaky_examples[new_example.uid].to_h)
+ .to eq(expected_flaky_example.merge(last_flaky_at: the_future))
end
-
- expect(listener.flaky_examples[new_example.uid].to_h)
- .to eq(expected_flaky_example.merge(last_flaky_at: now))
end
end
@@ -157,14 +162,13 @@ RSpec.describe RspecFlaky::Listener, :aggregate_failures do
it 'changes the all flaky examples hash' do
new_example = RspecFlaky::Example.new(rspec_example)
- now = Time.now
- Timecop.freeze(now) do
+ travel_to(Time.now + 42) do
+ the_future = Time.now
expect { listener.example_passed(notification) }
.to change { listener.flaky_examples[new_example.uid].to_h }
+ expect(listener.flaky_examples[new_example.uid].to_h)
+ .to eq(expected_flaky_example.merge(first_flaky_at: the_future, last_flaky_at: the_future))
end
-
- expect(listener.flaky_examples[new_example.uid].to_h)
- .to eq(expected_flaky_example.merge(first_flaky_at: now, last_flaky_at: now))
end
end
@@ -198,6 +202,10 @@ RSpec.describe RspecFlaky::Listener, :aggregate_failures do
let(:notification_new_flaky_rspec_example) { double(example: new_flaky_rspec_example) }
let(:notification_already_flaky_rspec_example) { double(example: already_flaky_rspec_example) }
+ before do
+ allow(Kernel).to receive(:warn)
+ end
+
context 'when a report file path is set by FLAKY_RSPEC_REPORT_PATH' do
it 'delegates the writes to RspecFlaky::Report' do
listener.example_passed(notification_new_flaky_rspec_example)
diff --git a/spec/lib/rspec_flaky/report_spec.rb b/spec/tooling/rspec_flaky/report_spec.rb
index 5cacfdb82fb..6c364cd5cd3 100644
--- a/spec/lib/rspec_flaky/report_spec.rb
+++ b/spec/tooling/rspec_flaky/report_spec.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
-require 'spec_helper'
+require 'tempfile'
+
+require_relative '../../../tooling/rspec_flaky/report'
RSpec.describe RspecFlaky::Report, :aggregate_failures do
let(:thirty_one_days) { 3600 * 24 * 31 }
@@ -30,10 +32,14 @@ RSpec.describe RspecFlaky::Report, :aggregate_failures do
let(:flaky_examples) { RspecFlaky::FlakyExamplesCollection.new(collection_hash) }
let(:report) { described_class.new(flaky_examples) }
+ before do
+ allow(Kernel).to receive(:warn)
+ end
+
describe '.load' do
let!(:report_file) do
Tempfile.new(%w[rspec_flaky_report .json]).tap do |f|
- f.write(Gitlab::Json.pretty_generate(suite_flaky_example_report))
+ f.write(JSON.pretty_generate(suite_flaky_example_report)) # rubocop:disable Gitlab/Json
f.rewind
end
end
@@ -50,7 +56,7 @@ RSpec.describe RspecFlaky::Report, :aggregate_failures do
describe '.load_json' do
let(:report_json) do
- Gitlab::Json.pretty_generate(suite_flaky_example_report)
+ JSON.pretty_generate(suite_flaky_example_report) # rubocop:disable Gitlab/Json
end
it 'loads the report file' do
@@ -73,7 +79,7 @@ RSpec.describe RspecFlaky::Report, :aggregate_failures do
end
describe '#write' do
- let(:report_file_path) { Rails.root.join('tmp', 'rspec_flaky_report.json') }
+ let(:report_file_path) { File.join('tmp', 'rspec_flaky_report.json') }
before do
FileUtils.rm(report_file_path) if File.exist?(report_file_path)
@@ -105,7 +111,7 @@ RSpec.describe RspecFlaky::Report, :aggregate_failures do
expect(File.exist?(report_file_path)).to be(true)
expect(File.read(report_file_path))
- .to eq(Gitlab::Json.pretty_generate(report.flaky_examples.to_h))
+ .to eq(JSON.pretty_generate(report.flaky_examples.to_h)) # rubocop:disable Gitlab/Json
end
end
end
diff --git a/tooling/rspec_flaky/config.rb b/tooling/rspec_flaky/config.rb
new file mode 100644
index 00000000000..ea18a601c11
--- /dev/null
+++ b/tooling/rspec_flaky/config.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module RspecFlaky
+ class Config
+ def self.generate_report?
+ !!(ENV['FLAKY_RSPEC_GENERATE_REPORT'] =~ /1|true/)
+ end
+
+ def self.suite_flaky_examples_report_path
+ ENV['SUITE_FLAKY_RSPEC_REPORT_PATH'] || rails_path("rspec_flaky/suite-report.json")
+ end
+
+ def self.flaky_examples_report_path
+ ENV['FLAKY_RSPEC_REPORT_PATH'] || rails_path("rspec_flaky/report.json")
+ end
+
+ def self.new_flaky_examples_report_path
+ ENV['NEW_FLAKY_RSPEC_REPORT_PATH'] || rails_path("rspec_flaky/new-report.json")
+ end
+
+ def self.rails_path(path)
+ return path unless defined?(Rails)
+
+ Rails.root.join(path)
+ end
+ end
+end
diff --git a/lib/rspec_flaky/example.rb b/tooling/rspec_flaky/example.rb
index 3c1b05257a0..18f8c5acc1c 100644
--- a/lib/rspec_flaky/example.rb
+++ b/tooling/rspec_flaky/example.rb
@@ -1,12 +1,17 @@
# frozen_string_literal: true
+require 'forwardable'
+require 'digest'
+
module RspecFlaky
# This is a wrapper class for RSpec::Core::Example
class Example
- delegate :status, :exception, to: :execution_result
+ extend Forwardable
+
+ def_delegators :execution_result, :status, :exception
def initialize(rspec_example)
- @rspec_example = rspec_example.try(:example) || rspec_example
+ @rspec_example = rspec_example.respond_to?(:example) ? rspec_example.example : rspec_example
end
def uid
@@ -30,7 +35,7 @@ module RspecFlaky
end
def attempts
- rspec_example.try(:attempts) || 1
+ rspec_example.respond_to?(:attempts) ? rspec_example.attempts : 1
end
private
diff --git a/lib/rspec_flaky/flaky_example.rb b/tooling/rspec_flaky/flaky_example.rb
index da5dbf06bc9..4f3688dbeed 100644
--- a/lib/rspec_flaky/flaky_example.rb
+++ b/tooling/rspec_flaky/flaky_example.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+require 'ostruct'
+
module RspecFlaky
# This represents a flaky RSpec example and is mainly meant to be saved in a JSON file
class FlakyExample < OpenStruct
diff --git a/lib/rspec_flaky/flaky_examples_collection.rb b/tooling/rspec_flaky/flaky_examples_collection.rb
index acbfb411873..acbfb411873 100644
--- a/lib/rspec_flaky/flaky_examples_collection.rb
+++ b/tooling/rspec_flaky/flaky_examples_collection.rb
diff --git a/lib/rspec_flaky/listener.rb b/tooling/rspec_flaky/listener.rb
index 37e4e16e87e..a5c68d830db 100644
--- a/lib/rspec_flaky/listener.rb
+++ b/tooling/rspec_flaky/listener.rb
@@ -2,11 +2,11 @@
require 'json'
-require_dependency 'rspec_flaky/config'
-require_dependency 'rspec_flaky/example'
-require_dependency 'rspec_flaky/flaky_example'
-require_dependency 'rspec_flaky/flaky_examples_collection'
-require_dependency 'rspec_flaky/report'
+require_relative 'config'
+require_relative 'example'
+require_relative 'flaky_example'
+require_relative 'flaky_examples_collection'
+require_relative 'report'
module RspecFlaky
class Listener
@@ -32,21 +32,19 @@ module RspecFlaky
flaky_examples[current_example.uid] = flaky_example
end
- # rubocop:disable Gitlab/RailsLogger
def dump_summary(_)
RspecFlaky::Report.new(flaky_examples).write(RspecFlaky::Config.flaky_examples_report_path)
# write_report_file(flaky_examples, RspecFlaky::Config.flaky_examples_report_path)
new_flaky_examples = flaky_examples - suite_flaky_examples
if new_flaky_examples.any?
- Rails.logger.warn "\nNew flaky examples detected:\n"
- Rails.logger.warn Gitlab::Json.pretty_generate(new_flaky_examples.to_h)
+ rails_logger_warn("\nNew flaky examples detected:\n")
+ rails_logger_warn(JSON.pretty_generate(new_flaky_examples.to_h)) # rubocop:disable Gitlab/Json
RspecFlaky::Report.new(new_flaky_examples).write(RspecFlaky::Config.new_flaky_examples_report_path)
# write_report_file(new_flaky_examples, RspecFlaky::Config.new_flaky_examples_report_path)
end
end
- # rubocop:enable Gitlab/RailsLogger
private
@@ -59,5 +57,11 @@ module RspecFlaky
RspecFlaky::Report.load(RspecFlaky::Config.suite_flaky_examples_report_path).flaky_examples
end
end
+
+ def rails_logger_warn(text)
+ target = defined?(Rails) ? Rails.logger : Kernel
+
+ target.warn(text)
+ end
end
end
diff --git a/lib/rspec_flaky/report.rb b/tooling/rspec_flaky/report.rb
index 73f30362cfe..3acfe7d2125 100644
--- a/lib/rspec_flaky/report.rb
+++ b/tooling/rspec_flaky/report.rb
@@ -3,8 +3,8 @@
require 'json'
require 'time'
-require_dependency 'rspec_flaky/config'
-require_dependency 'rspec_flaky/flaky_examples_collection'
+require_relative 'config'
+require_relative 'flaky_examples_collection'
module RspecFlaky
# This class is responsible for loading/saving JSON reports, and pruning
@@ -33,7 +33,7 @@ module RspecFlaky
def write(file_path)
unless RspecFlaky::Config.generate_report?
- puts "! Generating reports is disabled. To enable it, please set the `FLAKY_RSPEC_GENERATE_REPORT=1` !" # rubocop:disable Rails/Output
+ Kernel.warn "! Generating reports is disabled. To enable it, please set the `FLAKY_RSPEC_GENERATE_REPORT=1` !"
return
end