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:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-04-25 15:18:56 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-04-25 15:18:56 +0300
commitd2d913b606702ecefa01f03362602fde256e3f75 (patch)
tree07643306ee63f789188a9133823aac3c92c94dfb
parentaf69e63b6655a450849a8fa2640ae6ce5a8db681 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.rubocop_todo/layout/argument_alignment.yml14
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.checksum3
-rw-r--r--Gemfile.lock4
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue7
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/index.js2
-rw-r--r--app/assets/javascripts/ci/reports/components/report_item.vue2
-rw-r--r--app/assets/javascripts/issuable/components/related_issuable_item.vue32
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue2
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue2
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue2
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js1
-rw-r--r--app/assets/javascripts/related_issues/index.js1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/constants.js8
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue47
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_discussion.vue3
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note.vue8
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue13
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue30
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail_modal.vue5
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/index.js2
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue23
-rw-r--r--app/assets/javascripts/work_items/components/work_item_notes.vue8
-rw-r--r--app/assets/javascripts/work_items/index.js2
-rw-r--r--app/assets/stylesheets/pages/labels.scss1
-rw-r--r--app/assets/stylesheets/utilities.scss4
-rw-r--r--app/helpers/sidebars_helper.rb2
-rw-r--r--app/helpers/work_items_helper.rb3
-rw-r--r--app/models/badges/project_badge.rb2
-rw-r--r--app/models/board.rb2
-rw-r--r--app/models/bulk_imports/export.rb1
-rw-r--r--app/models/bulk_imports/file_transfer/base_config.rb22
-rw-r--r--app/models/external_pull_request.rb1
-rw-r--r--app/models/label.rb1
-rw-r--r--app/models/milestone.rb1
-rw-r--r--app/models/project_custom_attribute.rb2
-rw-r--r--app/models/protected_branch.rb1
-rw-r--r--app/models/protected_tag.rb1
-rw-r--r--app/models/snippet.rb1
-rw-r--r--app/services/bulk_imports/batched_relation_export_service.rb91
-rw-r--r--app/services/bulk_imports/export_service.rb19
-rw-r--r--app/services/bulk_imports/file_export_service.rb49
-rw-r--r--app/services/bulk_imports/lfs_objects_export_service.rb14
-rw-r--r--app/services/bulk_imports/relation_batch_export_service.rb80
-rw-r--r--app/services/bulk_imports/relation_export_service.rb54
-rw-r--r--app/services/bulk_imports/repository_bundle_export_service.rb2
-rw-r--r--app/services/bulk_imports/tree_export_service.rb43
-rw-r--r--app/services/bulk_imports/uploads_export_service.rb14
-rw-r--r--app/services/ci/pipeline_processing/atomic_processing_service.rb3
-rw-r--r--app/services/system_notes/time_tracking_service.rb2
-rw-r--r--app/views/dashboard/_no_filter_selected.html.haml (renamed from app/views/shared/dashboard/_no_filter_selected.html.haml)0
-rw-r--r--app/views/dashboard/issues.html.haml17
-rw-r--r--app/views/dashboard/merge_requests.html.haml2
-rw-r--r--app/views/layouts/project.html.haml1
-rw-r--r--app/views/projects/issues/_related_issues.html.haml3
-rw-r--r--app/views/projects/merge_requests/_widget.html.haml2
-rw-r--r--app/views/shared/_issues.html.haml8
-rw-r--r--app/views/shared/_label.html.haml40
-rw-r--r--app/views/shared/labels/_form.html.haml2
-rw-r--r--app/workers/all_queues.yml18
-rw-r--r--app/workers/bulk_imports/finish_batched_relation_export_worker.rb62
-rw-r--r--app/workers/bulk_imports/relation_batch_export_worker.rb16
-rw-r--r--app/workers/bulk_imports/relation_export_worker.rb9
-rw-r--r--app/workers/pipeline_process_worker.rb12
-rw-r--r--config/feature_flags/development/ci_pipeline_process_worker_dedup_until_executed.yml8
-rw-r--r--config/feature_flags/development/vue_issues_dashboard.yml8
-rw-r--r--config/initializers/active_record_transaction_observer.rb15
-rw-r--r--config/sidekiq_queues.yml4
-rw-r--r--doc/integration/jira/issues.md2
-rw-r--r--doc/tutorials/boards_for_teams/index.md202
-rw-r--r--doc/user/application_security/index.md3
-rw-r--r--doc/user/application_security/secret_detection/index.md2
-rw-r--r--doc/user/compliance/img/license-check_v13_4.pngbin25590 -> 0 bytes
-rw-r--r--doc/user/compliance/img/policies_maintainer_add_v14_3.pngbin49418 -> 0 bytes
-rw-r--r--doc/user/compliance/img/policies_maintainer_edit_v14_3.pngbin26480 -> 0 bytes
-rw-r--r--doc/user/compliance/img/policies_v13_0.pngbin22618 -> 0 bytes
-rw-r--r--doc/user/compliance/license_check_rules.md84
-rw-r--r--doc/user/compliance/license_compliance/index.md10
-rw-r--r--doc/user/project/issue_board.md5
-rw-r--r--doc/user/ssh.md2
-rw-r--r--lib/feature.rb13
-rw-r--r--lib/gitlab/import_export/json/streaming_serializer.rb30
-rw-r--r--lib/gitlab/json.rb2
-rw-r--r--lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job.rb20
-rw-r--r--locale/gitlab.pot77
-rw-r--r--package.json2
-rw-r--r--spec/factories/bulk_import/export_batches.rb1
-rw-r--r--spec/factories/bulk_import/exports.rb4
-rw-r--r--spec/factories/ci/processable.rb20
-rw-r--r--spec/factories/draft_note.rb4
-rw-r--r--spec/factories/environments.rb27
-rw-r--r--spec/factories/group_members.rb10
-rw-r--r--spec/factories/ml/candidates.rb10
-rw-r--r--spec/factories/notes.rb40
-rw-r--r--spec/features/dashboard/issuables_counter_spec.rb2
-rw-r--r--spec/features/dashboard/issues_filter_spec.rb73
-rw-r--r--spec/features/dashboard/issues_spec.rb43
-rw-r--r--spec/features/dashboard/label_filter_spec.rb34
-rw-r--r--spec/features/groups/labels/index_spec.rb3
-rw-r--r--spec/features/merge_requests/filters_generic_behavior_spec.rb2
-rw-r--r--spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap1
-rw-r--r--spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap4
-rw-r--r--spec/frontend/issuable/components/related_issuable_item_spec.js43
-rw-r--r--spec/frontend/issuable/related_issues/components/related_issues_block_spec.js157
-rw-r--r--spec/frontend/issuable/related_issues/components/related_issues_list_spec.js93
-rw-r--r--spec/frontend/issuable/related_issues/components/related_issues_root_spec.js3
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js28
-rw-r--r--spec/frontend/work_items/components/notes/work_item_note_actions_spec.js29
-rw-r--r--spec/frontend/work_items/components/notes/work_item_note_spec.js18
-rw-r--r--spec/frontend/work_items/components/work_item_detail_spec.js30
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_links_spec.js34
-rw-r--r--spec/frontend/work_items/components/work_item_notes_spec.js3
-rw-r--r--spec/frontend/work_items/router_spec.js1
-rw-r--r--spec/helpers/sidebars_helper_spec.rb2
-rw-r--r--spec/helpers/work_items_helper_spec.rb24
-rw-r--r--spec/initializers/active_record_transaction_observer_spec.rb49
-rw-r--r--spec/lib/feature_spec.rb26
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb21
-rw-r--r--spec/models/bulk_imports/file_transfer/group_config_spec.rb49
-rw-r--r--spec/models/bulk_imports/file_transfer/project_config_spec.rb29
-rw-r--r--spec/services/bulk_imports/batched_relation_export_service_spec.rb104
-rw-r--r--spec/services/bulk_imports/export_service_spec.rb51
-rw-r--r--spec/services/bulk_imports/file_export_service_spec.rb62
-rw-r--r--spec/services/bulk_imports/lfs_objects_export_service_spec.rb23
-rw-r--r--spec/services/bulk_imports/relation_batch_export_service_spec.rb67
-rw-r--r--spec/services/bulk_imports/relation_export_service_spec.rb22
-rw-r--r--spec/services/bulk_imports/tree_export_service_spec.rb10
-rw-r--r--spec/services/bulk_imports/uploads_export_service_spec.rb33
-rw-r--r--spec/services/system_notes/time_tracking_service_spec.rb6
-rw-r--r--spec/spec_helper.rb4
-rw-r--r--spec/support/helpers/filtered_search_helpers.rb6
-rw-r--r--spec/views/projects/issues/_related_issues.html.haml_spec.rb37
-rw-r--r--spec/workers/bulk_imports/finish_batched_relation_export_worker_spec.rb80
-rw-r--r--spec/workers/bulk_imports/relation_batch_export_worker_spec.rb26
-rw-r--r--spec/workers/bulk_imports/relation_export_worker_spec.rb47
-rw-r--r--spec/workers/pipeline_process_worker_spec.rb42
-rw-r--r--yarn.lock8
147 files changed, 2172 insertions, 704 deletions
diff --git a/.rubocop_todo/layout/argument_alignment.yml b/.rubocop_todo/layout/argument_alignment.yml
index ab1cda9e473..15e8912cffb 100644
--- a/.rubocop_todo/layout/argument_alignment.yml
+++ b/.rubocop_todo/layout/argument_alignment.yml
@@ -1179,14 +1179,6 @@ Layout/ArgumentAlignment:
- 'ee/spec/elastic/migrate/20220119120500_populate_commit_permissions_in_main_index_spec.rb'
- 'ee/spec/elastic/migrate/20221124090600_add_namespace_ancestry_ids_to_original_index_mapping_spec.rb'
- 'ee/spec/elastic/migrate/20221221110300_backfill_traversal_ids_to_blobs_and_wiki_blobs_spec.rb'
- - 'ee/spec/factories/epic_tree_nodes.rb'
- - 'ee/spec/factories/groups.rb'
- - 'ee/spec/factories/import_states.rb'
- - 'ee/spec/factories/merge_requests.rb'
- - 'ee/spec/factories/namespaces.rb'
- - 'ee/spec/factories/projects.rb'
- - 'ee/spec/factories/security_scans.rb'
- - 'ee/spec/factories/vulnerabilities/findings.rb'
- 'ee/spec/features/account_recovery_regular_check_spec.rb'
- 'ee/spec/features/admin/admin_emails_spec.rb'
- 'ee/spec/features/admin/admin_settings_spec.rb'
@@ -1902,12 +1894,6 @@ Layout/ArgumentAlignment:
- 'spec/components/previews/pajamas/alert_component_preview.rb'
- 'spec/components/previews/pajamas/banner_component_preview.rb'
- 'spec/components/previews/pajamas/button_component_preview.rb'
- - 'spec/factories/ci/processable.rb'
- - 'spec/factories/draft_note.rb'
- - 'spec/factories/environments.rb'
- - 'spec/factories/group_members.rb'
- - 'spec/factories/ml/candidates.rb'
- - 'spec/factories/notes.rb'
- 'spec/features/admin/admin_mode/login_spec.rb'
- 'spec/features/admin/integrations/user_activates_mattermost_slash_command_spec.rb'
- 'spec/features/atom/issues_spec.rb'
diff --git a/Gemfile b/Gemfile
index 48eb85f4746..a879a4e4c61 100644
--- a/Gemfile
+++ b/Gemfile
@@ -205,7 +205,7 @@ gem 'diffy', '~> 3.4'
gem 'diff_match_patch', '~> 0.1.0'
# Application server
-gem 'rack', '~> 2.2.6', '>= 2.2.6.4'
+gem 'rack', '~> 2.2.7'
# https://github.com/zombocom/rack-timeout/blob/master/README.md#rails-apps-manually
gem 'rack-timeout', '~> 0.6.3', require: 'rack/timeout/base'
diff --git a/Gemfile.checksum b/Gemfile.checksum
index 1458f2f442e..2cb4d99b12e 100644
--- a/Gemfile.checksum
+++ b/Gemfile.checksum
@@ -463,7 +463,7 @@
{"name":"raabro","version":"1.4.0","platform":"ruby","checksum":"d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882"},
{"name":"racc","version":"1.6.2","platform":"java","checksum":"0880781e7dfde09e665d0b6160b583e01ed52fcc2955d7891447d33c2d1d2cf1"},
{"name":"racc","version":"1.6.2","platform":"ruby","checksum":"58d26b3666382396fea84d33dc0639b7ee8d704156a52f8f22681f07b2f94f26"},
-{"name":"rack","version":"2.2.6.4","platform":"ruby","checksum":"d3d92be402b5881058caccc0975e6d67a1e0ba929d1d144a43daf689169bfce1"},
+{"name":"rack","version":"2.2.7","platform":"ruby","checksum":"b3377e8b2227b8ffa6b617ef8649ffb5e265e46ca8fa1f31244c809fe609829b"},
{"name":"rack-accept","version":"0.4.5","platform":"ruby","checksum":"66247b5449db64ebb93ae2ec4af4764b87d1ae8a7463c7c68893ac13fa8d4da2"},
{"name":"rack-attack","version":"6.6.1","platform":"ruby","checksum":"187e5d248c6a162ed8cafa8241a7b5947d9b9cf122a4870eb1cdd0db861f3a11"},
{"name":"rack-cors","version":"1.1.1","platform":"ruby","checksum":"4702644ac6d63ebbddff372a3cd4cd573513287e3524b5a5415f678970057a4b"},
@@ -563,7 +563,6 @@
{"name":"sentry-ruby","version":"5.8.0","platform":"ruby","checksum":"caeb121433be379fb94e991a45265a287b13a9a9083e7264f539752369d37110"},
{"name":"sentry-sidekiq","version":"5.8.0","platform":"ruby","checksum":"90d1123d16a9fc5fd99dbad190b766dd189eaf9e2baddad641f1334e1877c779"},
{"name":"set","version":"1.0.1","platform":"ruby","checksum":"d169fe8df4738e9da1118199429a9cf1ce0ac5e8a3cacc481e2ed24d585419dd"},
-{"name":"settingslogic","version":"2.0.9","platform":"ruby","checksum":"5925a91d0d48dfb59a6e48ae2bb9c9b801fe6fab25a8e8d302ce8699d92f2ae6"},
{"name":"sexp_processor","version":"4.16.1","platform":"ruby","checksum":"5caadbf4bbe5ab539cb892a5bcf74ca33a2f2a897cecafdee4a63be79b4819dc"},
{"name":"shellany","version":"0.0.1","platform":"ruby","checksum":"0e127a9132698766d7e752e82cdac8250b6adbd09e6c0a7fbbb6f61964fedee7"},
{"name":"shoulda-matchers","version":"5.1.0","platform":"ruby","checksum":"a01d20589989e9653ab4a28c67d9db2b82bcf0a2496cf01d5e1a95a4aaaf5b07"},
diff --git a/Gemfile.lock b/Gemfile.lock
index d78e04bbeff..7848ecd3f22 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1174,7 +1174,7 @@ GEM
pyu-ruby-sasl (0.0.3.3)
raabro (1.4.0)
racc (1.6.2)
- rack (2.2.6.4)
+ rack (2.2.7)
rack-accept (0.4.5)
rack (>= 0.4)
rack-attack (6.6.1)
@@ -1871,7 +1871,7 @@ DEPENDENCIES
pry-shell (~> 0.6.1)
puma (~> 5.6.5)
puma_worker_killer (~> 0.3.1)
- rack (~> 2.2.6, >= 2.2.6.4)
+ rack (~> 2.2.7)
rack-attack (~> 6.6.1)
rack-cors (~> 1.1.1)
rack-oauth2 (~> 1.21.3)
diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue
index 6842373fb61..1e158baa925 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue
@@ -11,6 +11,7 @@ import {
import FormattedStageCount from '~/analytics/cycle_analytics/components/formatted_stage_count.vue';
import { __ } from '~/locale';
import Tracking from '~/tracking';
+import { scrollToElement } from '~/lib/utils/common_utils';
import {
NOT_ENOUGH_DATA_ERROR,
FIELD_KEY_TITLE,
@@ -171,6 +172,7 @@ export default {
const { sort, direction } = this.pagination;
this.track('click_button', { label: 'pagination' });
this.$emit('handleUpdatePagination', { sort, direction, page });
+ this.scrollToTop();
},
onSort({ sortBy, sortDesc }) {
const direction = sortDesc ? PAGINATION_SORT_DIRECTION_DESC : PAGINATION_SORT_DIRECTION_ASC;
@@ -179,11 +181,14 @@ export default {
this.$emit('handleUpdatePagination', { sort: sortBy, direction });
this.track('click_button', { label: `sort_${sortBy}_${direction}` });
},
+ scrollToTop() {
+ scrollToElement(this.$el);
+ },
},
};
</script>
<template>
- <div data-testid="vsa-stage-table">
+ <div data-testid="vsa-stage-table" :class="{ 'gl-min-h-100vh': isLoading || !isEmptyStage }">
<gl-loading-icon v-if="isLoading" class="gl-mt-4" size="lg" />
<gl-empty-state
v-else-if="isEmptyStage"
diff --git a/app/assets/javascripts/ci/ci_variable_list/index.js b/app/assets/javascripts/ci/ci_variable_list/index.js
index 3ed56201f0d..033cdbe864e 100644
--- a/app/assets/javascripts/ci/ci_variable_list/index.js
+++ b/app/assets/javascripts/ci/ci_variable_list/index.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { GlToast } from '@gitlab/ui';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import CiAdminVariables from './components/ci_admin_variables.vue';
@@ -40,6 +41,7 @@ const mountCiVariableListApp = (containerEl) => {
component = CiProjectVariables;
}
+ Vue.use(GlToast);
Vue.use(VueApollo);
// If the feature flag `ci_variables_pages` is enabled,
diff --git a/app/assets/javascripts/ci/reports/components/report_item.vue b/app/assets/javascripts/ci/reports/components/report_item.vue
index 97d4ac7bf6f..ca1f3301691 100644
--- a/app/assets/javascripts/ci/reports/components/report_item.vue
+++ b/app/assets/javascripts/ci/reports/components/report_item.vue
@@ -53,7 +53,7 @@ export default {
};
</script>
<template>
- <li class="report-block-list-issue align-items-center" data-qa-selector="report_item_row">
+ <li class="report-block-list-issue gl-p-3!" data-qa-selector="report_item_row">
<component
:is="iconComponent"
v-if="showReportSectionStatusIcon"
diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue
index d32336395dc..63386d2e33d 100644
--- a/app/assets/javascripts/issuable/components/related_issuable_item.vue
+++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue
@@ -4,12 +4,13 @@ import { GlIcon, GlLink, GlTooltip, GlTooltipDirective, GlButton } from '@gitlab
import SafeHtml from '~/vue_shared/directives/safe_html';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
-import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isMetaKey } from '~/lib/utils/common_utils';
import { setUrlParams, updateHistory } from '~/lib/utils/url_utility';
import { sprintf } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import relatedIssuableMixin from '../mixins/related_issuable_mixin';
import IssueAssignees from './issue_assignees.vue';
import IssueMilestone from './issue_milestone.vue';
@@ -26,12 +27,18 @@ export default {
IssueDueDate,
GlButton,
WorkItemDetailModal,
+ AbuseCategorySelector,
},
directives: {
GlTooltip: GlTooltipDirective,
SafeHtml,
},
mixins: [relatedIssuableMixin],
+ inject: {
+ reportAbusePath: {
+ default: '',
+ },
+ },
props: {
canReorder: {
type: Boolean,
@@ -54,6 +61,13 @@ export default {
default: '',
},
},
+ data() {
+ return {
+ isReportDrawerOpen: false,
+ reportedUserId: 0,
+ reportedUrl: '',
+ };
+ },
computed: {
stateTitle() {
return sprintf(
@@ -92,6 +106,14 @@ export default {
replace: true,
});
},
+ toggleReportAbuseDrawer(isOpen, reply = {}) {
+ this.isReportDrawerOpen = isOpen;
+ this.reportedUrl = reply.url;
+ this.reportedUserId = reply.author ? getIdFromGraphQLId(reply.author.id) : 0;
+ },
+ openReportAbuseDrawer(reply) {
+ this.toggleReportAbuseDrawer(true, reply);
+ },
},
};
</script>
@@ -233,6 +255,14 @@ export default {
:work-item-id="workItemId"
@close="updateWorkItemIdUrlQuery"
@workItemDeleted="handleWorkItemDeleted"
+ @openReportAbuse="openReportAbuseDrawer"
+ />
+ <abuse-category-selector
+ v-if="isReportDrawerOpen && reportAbusePath"
+ :reported-user-id="reportedUserId"
+ :reported-from-url="reportedUrl"
+ :show-drawer="isReportDrawerOpen"
+ @close-drawer="toggleReportAbuseDrawer(false)"
/>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 34ae0c7ffc1..2cf6e9bb180 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -338,7 +338,7 @@ export default {
<form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form">
<comment-field-layout
:noteable-data="getNoteableData"
- :is-internal-note="discussion.internal"
+ :is-internal-note="discussionNote.internal"
>
<markdown-editor
ref="markdownEditor"
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
index 2cfedd78bd8..50e34f4e4f8 100644
--- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
+++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
@@ -332,7 +332,7 @@ export default {
</div>
</div>
- <p class="gl-mt-n5 gl-text-gray-500">
+ <p class="gl-mt-n3 gl-text-gray-500">
{{ s__('ForkProject|Want to organize several dependent projects under the same namespace?') }}
<gl-link :href="newGroupPath" target="_blank">
{{ s__('ForkProject|Create a group') }}
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue
index 12ddf538775..84796954cf1 100644
--- a/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue
+++ b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue
@@ -97,7 +97,7 @@ export default {
:no-results-text="__('No matches found')"
:searchable="true"
:searching="loading"
- toggle-class="gl-flex-direction-column gl-align-items-stretch!"
+ toggle-class="gl-flex-direction-column gl-align-items-stretch! gl-rounded-top-left-none! gl-rounded-bottom-left-none! gl-w-full!"
:toggle-text="dropdownText"
@search="searchNamespaces"
@select="setNamespace"
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index c9f43e43b2d..3130fe42c3c 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -24,7 +24,6 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-geo-migrate-hashed-storage-callout',
'.js-unlimited-members-during-trial-alert',
'.js-branch-rules-info-callout',
- '.js-license-check-deprecation-alert',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/related_issues/index.js b/app/assets/javascripts/related_issues/index.js
index cc00ef10dda..23620432feb 100644
--- a/app/assets/javascripts/related_issues/index.js
+++ b/app/assets/javascripts/related_issues/index.js
@@ -19,6 +19,7 @@ export function initRelatedIssues(issueType = TYPE_ISSUE) {
fullPath: el.dataset.fullPath,
hasIssueWeightsFeature: parseBoolean(el.dataset.hasIssueWeightsFeature),
hasIterationsFeature: parseBoolean(el.dataset.hasIterationsFeature),
+ reportAbusePath: el.dataset.reportAbusePath,
},
render: (createElement) =>
createElement(RelatedIssuesRoot, {
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index a7758191315..39ae1fda9f4 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -270,7 +270,7 @@ export default class MergeRequestStore {
this.conflictsDocsPath = data.conflicts_docs_path;
this.reviewingDocsPath = data.reviewing_and_managing_merge_requests_docs_path;
this.ciEnvironmentsStatusPath = data.ci_environments_status_path;
- this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path;
+ this.codeCoverageCheckHelpPagePath = data.code_coverage_check_help_page_path;
this.licenseComplianceDocsPath = data.license_compliance_docs_path;
this.eligibleApproversDocsPath = data.eligible_approvers_docs_path;
this.mergeImmediatelyDocsPath = data.merge_immediately_docs_path;
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js
index b66c89d1372..23b5af7fe5f 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js
@@ -1,4 +1,5 @@
import { s__, sprintf } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
export const REGISTRATION_TOKEN_PLACEHOLDER = '$REGISTRATION_TOKEN';
@@ -68,3 +69,10 @@ export const AWS_EASY_BUTTONS = [
),
},
];
+
+export const LEGACY_REGISTER_HELP_URL = helpPagePath(
+ 'architecture/blueprints/runner_tokens/index.md',
+ {
+ anchor: 'using-the-authentication-token-in-place-of-the-registration-token',
+ },
+);
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
index 22d9b88fa41..dcb98c2eede 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
@@ -7,14 +7,22 @@ import {
GlDropdown,
GlDropdownItem,
GlIcon,
+ GlLink,
GlLoadingIcon,
+ GlSprintf,
GlSkeletonLoader,
GlResizeObserverDirective,
} from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { __, s__ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getRunnerPlatformsQuery from './graphql/get_runner_platforms.query.graphql';
-import { PLATFORM_DOCKER, PLATFORM_KUBERNETES, PLATFORM_AWS } from './constants';
+import {
+ PLATFORM_DOCKER,
+ PLATFORM_KUBERNETES,
+ PLATFORM_AWS,
+ LEGACY_REGISTER_HELP_URL,
+} from './constants';
import RunnerCliInstructions from './instructions/runner_cli_instructions.vue';
import RunnerDockerInstructions from './instructions/runner_docker_instructions.vue';
@@ -30,13 +38,16 @@ export default {
GlDropdownItem,
GlModal,
GlIcon,
+ GlLink,
GlLoadingIcon,
+ GlSprintf,
GlSkeletonLoader,
RunnerDockerInstructions,
},
directives: {
GlResizeObserver: GlResizeObserverDirective,
},
+ mixins: [glFeatureFlagMixin()],
props: {
modalId: {
type: String,
@@ -91,7 +102,7 @@ export default {
shown: false,
platforms: [],
selectedPlatform: null,
- showAlert: false,
+ showErrorAlert: false,
platformsButtonGroupVertical: false,
};
},
@@ -111,6 +122,14 @@ export default {
return null;
}
},
+ showDeprecationAlert() {
+ return (
+ // create_runner_workflow_for_admin
+ this.glFeatures.createRunnerWorkflowForAdmin ||
+ // create_runner_workflow_for_namespace
+ this.glFeatures.createRunnerWorkflowForNamespace
+ );
+ },
},
updated() {
// Refocus on dom changes, after loading data
@@ -145,7 +164,7 @@ export default {
return this.selectedPlatform.name === platform.name;
},
toggleAlert(state) {
- this.showAlert = state;
+ this.showErrorAlert = state;
},
onPlatformsButtonResize() {
if (bp.getBreakpointSize() === 'xs') {
@@ -161,7 +180,12 @@ export default {
downloadInstallBinary: s__('Runners|Download and install binary'),
downloadLatestBinary: s__('Runners|Download latest binary'),
fetchError: s__('Runners|An error has occurred fetching instructions'),
+ deprecationAlertTitle: s__('Runners|Support for registration tokens is deprecated'),
+ deprecationAlertContent: s__(
+ "Runners|In GitLab Runner 15.6, the use of registration tokens and runner parameters in the 'register' command was deprecated. They have been replaced by authentication tokens. %{linkStart}How does this impact my current registration workflow?%{linkEnd}",
+ ),
},
+ LEGACY_REGISTER_HELP_URL,
};
</script>
<template>
@@ -174,7 +198,22 @@ export default {
v-on="$listeners"
@shown="onShown"
>
- <gl-alert v-if="showAlert" variant="danger" @dismiss="toggleAlert(false)">
+ <gl-alert
+ v-if="showDeprecationAlert"
+ :title="$options.i18n.deprecationAlertTitle"
+ variant="warning"
+ :dismissible="false"
+ >
+ <gl-sprintf :message="$options.i18n.deprecationAlertContent">
+ <template #link="{ content }">
+ <gl-link target="_blank" :href="$options.LEGACY_REGISTER_HELP_URL"
+ >{{ content }} <gl-icon name="external-link"
+ /></gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+
+ <gl-alert v-if="showErrorAlert" variant="danger" @dismiss="toggleAlert(false)">
{{ $options.i18n.fetchError }}
</gl-alert>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue
index 21fc8f99366..b1ed5cb6b3a 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue
@@ -171,6 +171,7 @@ export default {
:fetch-by-iid="fetchByIid"
@startReplying="showReplyForm"
@deleteNote="$emit('deleteNote', note)"
+ @reportAbuse="$emit('reportAbuse', note)"
@error="$emit('error', $event)"
/>
<timeline-entry-item
@@ -203,6 +204,7 @@ export default {
:fetch-by-iid="fetchByIid"
@startReplying="showReplyForm"
@deleteNote="$emit('deleteNote', note)"
+ @reportAbuse="$emit('reportAbuse', note)"
@error="$emit('error', $event)"
/>
<discussion-notes-replies-wrapper>
@@ -230,6 +232,7 @@ export default {
:fetch-by-iid="fetchByIid"
@startReplying="showReplyForm"
@deleteNote="$emit('deleteNote', reply)"
+ @reportAbuse="$emit('reportAbuse', reply)"
@error="$emit('error', $event)"
/>
</template>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_note.vue
index b8911592f5d..949dca72318 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note.vue
@@ -152,6 +152,12 @@ export default {
isAuthorAnAssignee() {
return Boolean(this.assignees.filter((assignee) => assignee.id === this.author.id).length);
},
+ currentUserId() {
+ return window.gon.current_user_id;
+ },
+ canReportAbuse() {
+ return getIdFromGraphQLId(this.author.id) !== this.currentUserId;
+ },
},
apollo: {
workItem: {
@@ -322,12 +328,14 @@ export default {
:note-id="note.id"
:is-author-an-assignee="isAuthorAnAssignee"
:show-assign-unassign="canSetWorkItemMetadata"
+ :can-report-abuse="canReportAbuse"
@startReplying="showReplyForm"
@startEditing="startEditing"
@error="($event) => $emit('error', $event)"
@notifyCopyDone="notifyCopyDone"
@deleteNote="$emit('deleteNote')"
@assignUser="assignUserAction"
+ @reportAbuse="$emit('reportAbuse')"
/>
</div>
</div>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue
index 624a532c2aa..93f21f4fad8 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue
@@ -15,6 +15,7 @@ export default {
copyLinkText: __('Copy link'),
assignUserText: __('Assign to commenting user'),
unassignUserText: __('Unassign from commenting user'),
+ reportAbuseText: __('Report abuse to administrator'),
},
components: {
GlButton,
@@ -61,6 +62,11 @@ export default {
required: false,
default: false,
},
+ canReportAbuse: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
assignUserActionText() {
@@ -141,6 +147,13 @@ export default {
no-caret
>
<gl-dropdown-item
+ v-if="canReportAbuse"
+ data-testid="abuse-note-action"
+ @click="$emit('reportAbuse')"
+ >
+ {{ $options.i18n.reportAbuseText }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
data-testid="copy-link-action"
:data-clipboard-text="noteUrl"
@click="$emit('notifyCopyDone')"
diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue
index c942278ba00..8028efcee8b 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -20,6 +20,7 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import {
sprintfWorkItem,
i18n,
@@ -91,9 +92,10 @@ export default {
WorkItemTree,
WorkItemNotes,
WorkItemDetailModal,
+ AbuseCategorySelector,
},
mixins: [glFeatureFlagMixin()],
- inject: ['fullPath'],
+ inject: ['fullPath', 'reportAbusePath'],
props: {
isModal: {
type: Boolean,
@@ -128,6 +130,9 @@ export default {
? convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId)
: null,
modalWorkItemIid: getParameterByName('work_item_iid'),
+ isReportDrawerOpen: false,
+ reportedUrl: '',
+ reportedUserId: 0,
};
},
apollo: {
@@ -498,7 +503,20 @@ export default {
this.modalWorkItemIid = modalWorkItem.iid;
this.$refs.modal.show();
},
+ openReportAbuseDrawer(reply) {
+ if (this.isModal) {
+ this.$emit('openReportAbuse', reply);
+ } else {
+ this.toggleReportAbuseDrawer(true, reply);
+ }
+ },
+ toggleReportAbuseDrawer(isOpen, reply = {}) {
+ this.isReportDrawerOpen = isOpen;
+ this.reportedUrl = reply.url || {};
+ this.reportedUserId = reply.author ? getIdFromGraphQLId(reply.author.id) : 0;
+ },
},
+
WORK_ITEM_TYPE_VALUE_OBJECTIVE,
};
</script>
@@ -731,9 +749,11 @@ export default {
:is-modal="isModal"
:assignees="workItemAssignees && workItemAssignees.assignees.nodes"
:can-set-work-item-metadata="canAssignUnassignUser"
+ :report-abuse-path="reportAbusePath"
class="gl-pt-5"
@error="updateError = $event"
@has-notes="updateHasNotes"
+ @openReportAbuse="openReportAbuseDrawer"
/>
<gl-empty-state
v-if="error"
@@ -749,6 +769,14 @@ export default {
:work-item-iid="modalWorkItemIid"
:show="true"
@close="updateUrl"
+ @openReportAbuse="toggleReportAbuseDrawer(true, $event)"
+ />
+ <abuse-category-selector
+ v-if="isReportDrawerOpen"
+ :reported-user-id="reportedUserId"
+ :reported-from-url="reportedUrl"
+ :show-drawer="true"
+ @close-drawer="toggleReportAbuseDrawer(false)"
/>
</section>
</section>
diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
index 51b957bb852..9d21dc1dad2 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
@@ -162,6 +162,10 @@ export default {
updateHasNotes() {
this.hasNotes = true;
},
+ openReportAbuseDrawer(reply) {
+ this.hide();
+ this.$emit('openReportAbuse', reply);
+ },
},
};
</script>
@@ -193,6 +197,7 @@ export default {
@deleteWorkItem="deleteWorkItem"
@update-modal="updateModal"
@has-notes="updateHasNotes"
+ @openReportAbuse="openReportAbuseDrawer"
/>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js
index 6b097f6b1ed..45dd4c00683 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/index.js
+++ b/app/assets/javascripts/work_items/components/work_item_links/index.js
@@ -19,6 +19,7 @@ export default function initWorkItemLinks() {
wiHasIssuableHealthStatusFeature,
registerPath,
signInPath,
+ wiReportAbusePath,
} = workItemLinksRoot.dataset;
// eslint-disable-next-line no-new
@@ -37,6 +38,7 @@ export default function initWorkItemLinks() {
hasIssuableHealthStatusFeature: wiHasIssuableHealthStatusFeature,
registerPath,
signInPath,
+ reportAbusePath: wiReportAbusePath,
},
render: (createElement) =>
createElement('work-item-links', {
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
index 64aa03b91ac..3c999d166dc 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
@@ -9,6 +9,7 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql';
import { isMetaKey } from '~/lib/utils/common_utils';
import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import {
FORM_TYPES,
@@ -37,12 +38,13 @@ export default {
WorkItemLinkChild,
WorkItemLinksForm,
WorkItemDetailModal,
+ AbuseCategorySelector,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagMixin()],
- inject: ['projectPath'],
+ inject: ['projectPath', 'reportAbusePath'],
props: {
workItemId: {
type: String,
@@ -105,6 +107,9 @@ export default {
parentIssue: null,
formType: null,
workItem: null,
+ isReportDrawerOpen: false,
+ reportedUserId: 0,
+ reportedUrl: '',
};
},
computed: {
@@ -277,6 +282,14 @@ export default {
clearPrefetching() {
clearTimeout(this.prefetch);
},
+ toggleReportAbuseDrawer(isOpen, reply = {}) {
+ this.isReportDrawerOpen = isOpen;
+ this.reportedUrl = reply.url;
+ this.reportedUserId = reply.author ? getIdFromGraphQLId(reply.author.id) : 0;
+ },
+ openReportAbuseDrawer(reply) {
+ this.toggleReportAbuseDrawer(true, reply);
+ },
},
i18n: {
title: s__('WorkItem|Tasks'),
@@ -374,6 +387,14 @@ export default {
:work-item-iid="activeChild.iid"
@close="closeModal"
@workItemDeleted="handleWorkItemDeleted(activeChild)"
+ @openReportAbuse="openReportAbuseDrawer"
+ />
+ <abuse-category-selector
+ v-if="isReportDrawerOpen && reportAbusePath"
+ :reported-user-id="reportedUserId"
+ :reported-from-url="reportedUrl"
+ :show-drawer="isReportDrawerOpen"
+ @close-drawer="toggleReportAbuseDrawer(false)"
/>
</template>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue
index 00cdc224320..3f6a0643313 100644
--- a/app/assets/javascripts/work_items/components/work_item_notes.vue
+++ b/app/assets/javascripts/work_items/components/work_item_notes.vue
@@ -89,6 +89,10 @@ export default {
required: false,
default: false,
},
+ reportAbusePath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -284,6 +288,9 @@ export default {
updateKey() {
this.addNoteKey = uniqueId(`work-item-add-note-${this.workItemId}`);
},
+ reportAbuse(isOpen, reply = {}) {
+ this.$emit('openReportAbuse', reply);
+ },
async fetchMoreNotes() {
this.isLoadingMore = true;
// copied from discussions batch logic - every fetchMore call has a higher
@@ -399,6 +406,7 @@ export default {
:assignees="assignees"
:can-set-work-item-metadata="canSetWorkItemMetadata"
@deleteNote="showDeleteNoteModal($event, discussion)"
+ @reportAbuse="reportAbuse(true, $event)"
@error="$emit('error', $event)"
/>
</template>
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
index 7c47e72e170..eb37336bbf9 100644
--- a/app/assets/javascripts/work_items/index.js
+++ b/app/assets/javascripts/work_items/index.js
@@ -19,6 +19,7 @@ export const initWorkItemsRoot = () => {
hasOkrsFeature,
hasIssuableHealthStatusFeature,
newCommentTemplatePath,
+ reportAbusePath,
} = el.dataset;
return new Vue({
@@ -37,6 +38,7 @@ export const initWorkItemsRoot = () => {
hasIterationsFeature: parseBoolean(hasIterationsFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
newCommentTemplatePath,
+ reportAbusePath,
},
render(createElement) {
return createElement(App);
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index 7c5054a6964..472e08bb427 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -115,6 +115,7 @@
}
.label-actions-list {
+ font-size: 0;
list-style: none;
flex-shrink: 0;
text-align: right;
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index e5f99879166..e3c901d787c 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -65,6 +65,10 @@
min-width: 0;
}
+.gl-min-h-100vh {
+ min-height: 100vh;
+}
+
// .gl-font-size-inherit will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1466
.gl-font-size-inherit,
.font-size-inherit { font-size: inherit; }
diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb
index 81ff29846c5..4dfc65dd849 100644
--- a/app/helpers/sidebars_helper.rb
+++ b/app/helpers/sidebars_helper.rb
@@ -318,7 +318,7 @@ module SidebarsHelper
if current_user&.can_admin_all_resources?
links.append(
- { title: s_('Navigation|Admin'), link: admin_root_path, icon: 'admin' }
+ { title: s_('Navigation|Admin Area'), link: admin_root_path, icon: 'admin' }
)
end
diff --git a/app/helpers/work_items_helper.rb b/app/helpers/work_items_helper.rb
index 6fa5c499ee2..9036c7c8347 100644
--- a/app/helpers/work_items_helper.rb
+++ b/app/helpers/work_items_helper.rb
@@ -7,7 +7,8 @@ module WorkItemsHelper
issues_list_path: project_issues_path(project),
register_path: new_user_registration_path(redirect_to_referer: 'yes'),
sign_in_path: new_session_path(:user, redirect_to_referer: 'yes'),
- new_comment_template_path: profile_comment_templates_path
+ new_comment_template_path: profile_comment_templates_path,
+ report_abuse_path: add_category_abuse_reports_path
}
end
end
diff --git a/app/models/badges/project_badge.rb b/app/models/badges/project_badge.rb
index 59638df6fad..8c51ebafb5e 100644
--- a/app/models/badges/project_badge.rb
+++ b/app/models/badges/project_badge.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class ProjectBadge < Badge
+ include EachBatch
+
belongs_to :project
validates :project, presence: true
diff --git a/app/models/board.rb b/app/models/board.rb
index 702ae0cc9f5..da9cd1548e4 100644
--- a/app/models/board.rb
+++ b/app/models/board.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Board < ApplicationRecord
+ include EachBatch
+
RECENT_BOARDS_SIZE = 4
belongs_to :group
diff --git a/app/models/bulk_imports/export.rb b/app/models/bulk_imports/export.rb
index 1ea317a100a..93cf047c690 100644
--- a/app/models/bulk_imports/export.rb
+++ b/app/models/bulk_imports/export.rb
@@ -33,6 +33,7 @@ module BulkImports
event :finish do
transition started: :finished
+ transition finished: :finished
transition failed: :failed
end
diff --git a/app/models/bulk_imports/file_transfer/base_config.rb b/app/models/bulk_imports/file_transfer/base_config.rb
index 67c4e7400b3..32fc794627c 100644
--- a/app/models/bulk_imports/file_transfer/base_config.rb
+++ b/app/models/bulk_imports/file_transfer/base_config.rb
@@ -32,6 +32,15 @@ module BulkImports
tree_relations + file_relations + self_relation - skipped_relations
end
+ def batchable_relations
+ portable_relations.select { |relation| portable_class.reflect_on_association(relation)&.collection? }
+ end
+ strong_memoize_attr :batchable_relations
+
+ def batchable_relation?(relation)
+ batchable_relations.include?(relation)
+ end
+
def self_relation?(relation)
relation == SELF_RELATION
end
@@ -55,6 +64,19 @@ module BulkImports
.find_relations_tree(portable_class_sym, include_import_only_tree: true).deep_stringify_keys
end
+ # Returns an export service class for the given relation.
+ # @return TreeExportService if a relation is serializable and is listed in import_export.yml
+ # @return FileExportService if a relation is a file (uploads, lfs objects, git repository, etc.)
+ def export_service_for(relation)
+ if tree_relation?(relation)
+ ::BulkImports::TreeExportService
+ elsif file_relation?(relation)
+ ::BulkImports::FileExportService
+ else
+ raise ::BulkImports::Error, 'Unsupported export relation'
+ end
+ end
+
private
attr_reader :portable
diff --git a/app/models/external_pull_request.rb b/app/models/external_pull_request.rb
index 4654f7e2341..94c242782c1 100644
--- a/app/models/external_pull_request.rb
+++ b/app/models/external_pull_request.rb
@@ -14,6 +14,7 @@
class ExternalPullRequest < Ci::ApplicationRecord
include Gitlab::Utils::StrongMemoize
include ShaAttribute
+ include EachBatch
belongs_to :project
diff --git a/app/models/label.rb b/app/models/label.rb
index aa53c0e0f3f..3d9ea39d860 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -9,6 +9,7 @@ class Label < ApplicationRecord
include Sortable
include FromUnion
include Presentable
+ include EachBatch
cache_markdown_field :description, pipeline: :single_line
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index 10d70eaa24e..d300b938fc0 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -9,6 +9,7 @@ class Milestone < ApplicationRecord
include Importable
include IidRoutes
include UpdatedAtFilterable
+ include EachBatch
prepend_mod_with('Milestone') # rubocop: disable Cop/InjectEnterpriseEditionModule
diff --git a/app/models/project_custom_attribute.rb b/app/models/project_custom_attribute.rb
index b0da586988a..8d3d45715ca 100644
--- a/app/models/project_custom_attribute.rb
+++ b/app/models/project_custom_attribute.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class ProjectCustomAttribute < ApplicationRecord
+ include EachBatch
+
belongs_to :project
validates :project, :key, :value, presence: true
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 01bdbba1955..105d22201df 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -4,6 +4,7 @@ class ProtectedBranch < ApplicationRecord
include ProtectedRef
include Gitlab::SQL::Pattern
include FromUnion
+ include EachBatch
belongs_to :group, foreign_key: :namespace_id, touch: true, inverse_of: :protected_branches
diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb
index e89cb3aabc7..5d215a364b7 100644
--- a/app/models/protected_tag.rb
+++ b/app/models/protected_tag.rb
@@ -2,6 +2,7 @@
class ProtectedTag < ApplicationRecord
include ProtectedRef
+ include EachBatch
validates :name, uniqueness: { scope: :project_id }
validates :project, presence: true
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 100d6f037a9..181130d45bd 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -19,6 +19,7 @@ class Snippet < ApplicationRecord
include AfterCommitQueue
extend ::Gitlab::Utils::Override
include CreatedAtFilterable
+ include EachBatch
MAX_FILE_COUNT = 10
diff --git a/app/services/bulk_imports/batched_relation_export_service.rb b/app/services/bulk_imports/batched_relation_export_service.rb
new file mode 100644
index 00000000000..778510f2e35
--- /dev/null
+++ b/app/services/bulk_imports/batched_relation_export_service.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+module BulkImports
+ class BatchedRelationExportService
+ include Gitlab::Utils::StrongMemoize
+
+ BATCH_SIZE = 1000
+ BATCH_CACHE_KEY = 'bulk_imports/batched_relation_export/%{export_id}/%{batch_id}'
+ CACHE_DURATION = 4.hours
+
+ def self.cache_key(export_id, batch_id)
+ Kernel.format(BATCH_CACHE_KEY, export_id: export_id, batch_id: batch_id)
+ end
+
+ def initialize(user, portable, relation, jid)
+ @user = user
+ @portable = portable
+ @relation = relation
+ @resolved_relation = portable.public_send(relation) # rubocop:disable GitlabSecurity/PublicSend
+ @jid = jid
+ end
+
+ def execute
+ return finish_export! if batches_count == 0
+
+ start_export!
+ export.batches.destroy_all # rubocop: disable Cop/DestroyAll
+ enqueue_batch_exports
+ rescue StandardError => e
+ fail_export!(e)
+ ensure
+ FinishBatchedRelationExportWorker.perform_async(export.id)
+ end
+
+ private
+
+ attr_reader :user, :portable, :relation, :jid, :config, :resolved_relation
+
+ def export
+ @export ||= portable.bulk_import_exports.find_or_create_by!(relation: relation) # rubocop:disable CodeReuse/ActiveRecord
+ end
+
+ def objects_count
+ resolved_relation.count
+ end
+
+ def batches_count
+ objects_count.fdiv(BATCH_SIZE).ceil
+ end
+
+ def start_export!
+ update_export!('start')
+ end
+
+ def finish_export!
+ update_export!('finish')
+ end
+
+ def update_export!(event)
+ export.update!(
+ status_event: event,
+ total_objects_count: objects_count,
+ batched: true,
+ batches_count: batches_count,
+ jid: jid,
+ error: nil
+ )
+ end
+
+ def enqueue_batch_exports
+ resolved_relation.each_batch(of: BATCH_SIZE) do |batch, batch_number|
+ batch_id = find_or_create_batch(batch_number).id
+ ids = batch.pluck(batch.model.primary_key) # rubocop:disable CodeReuse/ActiveRecord
+
+ Gitlab::Cache::Import::Caching.set_add(self.class.cache_key(export.id, batch_id), ids, timeout: CACHE_DURATION)
+
+ RelationBatchExportWorker.perform_async(user.id, batch_id)
+ end
+ end
+
+ def find_or_create_batch(batch_number)
+ export.batches.find_or_create_by!(batch_number: batch_number) # rubocop:disable CodeReuse/ActiveRecord
+ end
+
+ def fail_export!(exception)
+ Gitlab::ErrorTracking.track_exception(exception, portable_id: portable.id, portable_type: portable.class.name)
+
+ export.update!(status_event: 'fail_op', error: exception.message.truncate(255))
+ end
+ end
+end
diff --git a/app/services/bulk_imports/export_service.rb b/app/services/bulk_imports/export_service.rb
index 33b3a8e187f..1b60a8e0ff3 100644
--- a/app/services/bulk_imports/export_service.rb
+++ b/app/services/bulk_imports/export_service.rb
@@ -2,14 +2,20 @@
module BulkImports
class ExportService
- def initialize(portable:, user:)
+ # @param portable [Project|Group] A project or a group to export.
+ # @param user [User] A user performing the export.
+ # @param batched [Boolean] Whether to export the data in batches.
+ def initialize(portable:, user:, batched: false)
@portable = portable
@current_user = user
+ @batched = batched
end
def execute
+ validate_user_permissions!
+
FileTransfer.config_for(portable).portable_relations.each do |relation|
- RelationExportWorker.perform_async(current_user.id, portable.id, portable.class.name, relation)
+ RelationExportWorker.perform_async(current_user.id, portable.id, portable.class.name, relation, batched)
end
ServiceResponse.success
@@ -22,6 +28,13 @@ module BulkImports
private
- attr_reader :portable, :current_user
+ attr_reader :portable, :current_user, :batched
+
+ def validate_user_permissions!
+ ability = "admin_#{portable.to_ability_name}"
+
+ current_user.can?(ability, portable) ||
+ raise(::Gitlab::ImportExport::Error.permission_error(current_user, portable))
+ end
end
end
diff --git a/app/services/bulk_imports/file_export_service.rb b/app/services/bulk_imports/file_export_service.rb
index b2d114368a1..8b073f65769 100644
--- a/app/services/bulk_imports/file_export_service.rb
+++ b/app/services/bulk_imports/file_export_service.rb
@@ -4,39 +4,58 @@ module BulkImports
class FileExportService
include Gitlab::ImportExport::CommandLineUtil
- def initialize(portable, export_path, relation)
+ SINGLE_OBJECT_RELATIONS = [
+ FileTransfer::ProjectConfig::REPOSITORY_BUNDLE_RELATION,
+ FileTransfer::ProjectConfig::DESIGN_BUNDLE_RELATION
+ ].freeze
+
+ def initialize(portable, export_path, relation, user)
@portable = portable
@export_path = export_path
@relation = relation
+ @user = user # not used anywhere in this class at the moment
end
- def execute
- export_service.execute
+ def execute(options = {})
+ export_service.execute(options)
archive_exported_data
end
+ def export_batch(ids)
+ execute(batch_ids: ids)
+ end
+
def exported_filename
"#{relation}.tar"
end
+ def exported_objects_count
+ case relation
+ when *SINGLE_OBJECT_RELATIONS
+ 1
+ else
+ export_service.exported_objects_count
+ end
+ end
+
private
attr_reader :export_path, :portable, :relation
def export_service
- case relation
- when FileTransfer::BaseConfig::UPLOADS_RELATION
- UploadsExportService.new(portable, export_path)
- when FileTransfer::ProjectConfig::LFS_OBJECTS_RELATION
- LfsObjectsExportService.new(portable, export_path)
- when FileTransfer::ProjectConfig::REPOSITORY_BUNDLE_RELATION
- RepositoryBundleExportService.new(portable.repository, export_path, relation)
- when FileTransfer::ProjectConfig::DESIGN_BUNDLE_RELATION
- RepositoryBundleExportService.new(portable.design_repository, export_path, relation)
- else
- raise BulkImports::Error, 'Unsupported relation export type'
- end
+ @export_service ||= case relation
+ when FileTransfer::BaseConfig::UPLOADS_RELATION
+ UploadsExportService.new(portable, export_path)
+ when FileTransfer::ProjectConfig::LFS_OBJECTS_RELATION
+ LfsObjectsExportService.new(portable, export_path)
+ when FileTransfer::ProjectConfig::REPOSITORY_BUNDLE_RELATION
+ RepositoryBundleExportService.new(portable.repository, export_path, relation)
+ when FileTransfer::ProjectConfig::DESIGN_BUNDLE_RELATION
+ RepositoryBundleExportService.new(portable.design_repository, export_path, relation)
+ else
+ raise BulkImports::Error, 'Unsupported relation export type'
+ end
end
def archive_exported_data
diff --git a/app/services/bulk_imports/lfs_objects_export_service.rb b/app/services/bulk_imports/lfs_objects_export_service.rb
index b3b7cddf2d9..3020e8ababb 100644
--- a/app/services/bulk_imports/lfs_objects_export_service.rb
+++ b/app/services/bulk_imports/lfs_objects_export_service.rb
@@ -6,16 +6,26 @@ module BulkImports
BATCH_SIZE = 100
+ attr_reader :exported_objects_count
+
def initialize(portable, export_path)
@portable = portable
@export_path = export_path
@lfs_json = {}
+ @exported_objects_count = 0
end
- def execute
- portable.lfs_objects.find_in_batches(batch_size: BATCH_SIZE) do |batch| # rubocop: disable CodeReuse/ActiveRecord
+ def execute(options = {})
+ relation = portable.lfs_objects
+
+ if options[:batch_ids]
+ relation = relation.where(relation.model.primary_key => options[:batch_ids]) # rubocop:disable CodeReuse/ActiveRecord
+ end
+
+ relation.find_in_batches(batch_size: BATCH_SIZE) do |batch| # rubocop: disable CodeReuse/ActiveRecord
batch.each do |lfs_object|
save_lfs_object(lfs_object)
+ @exported_objects_count += 1
end
append_lfs_json_for_batch(batch)
diff --git a/app/services/bulk_imports/relation_batch_export_service.rb b/app/services/bulk_imports/relation_batch_export_service.rb
new file mode 100644
index 00000000000..19eb550216d
--- /dev/null
+++ b/app/services/bulk_imports/relation_batch_export_service.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+module BulkImports
+ class RelationBatchExportService
+ include Gitlab::ImportExport::CommandLineUtil
+
+ def initialize(user_id, batch_id)
+ @user = User.find(user_id)
+ @batch = BulkImports::ExportBatch.find(batch_id)
+ @config = FileTransfer.config_for(portable)
+ end
+
+ def execute
+ start_batch!
+
+ export_service.export_batch(relation_batch_ids)
+ compress_exported_relation
+ upload_compressed_file
+
+ finish_batch!
+ rescue StandardError => e
+ fail_batch!(e)
+ ensure
+ FileUtils.remove_entry(export_path)
+ end
+
+ private
+
+ attr_reader :user, :batch, :config
+
+ delegate :export_path, to: :config
+ delegate :batch_number, :export, to: :batch
+ delegate :portable, :relation, to: :export
+ delegate :exported_filename, :exported_objects_count, to: :export_service
+
+ def export_service
+ @export_service ||= config.export_service_for(relation).new(portable, export_path, relation, user)
+ end
+
+ def compress_exported_relation
+ gzip(dir: export_path, filename: exported_filename)
+ end
+
+ def upload_compressed_file
+ File.open(compressed_filename) { |file| batch_upload.export_file = file }
+
+ batch_upload.save!
+ end
+
+ def batch_upload
+ @batch_upload ||= ::BulkImports::ExportUpload.find_or_initialize_by(export_id: export.id, batch_id: batch.id) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ def compressed_filename
+ File.join(export_path, "#{exported_filename}.gz")
+ end
+
+ def relation_batch_ids
+ Gitlab::Cache::Import::Caching.values_from_set(cache_key).map(&:to_i)
+ end
+
+ def cache_key
+ BulkImports::BatchedRelationExportService.cache_key(export.id, batch.id)
+ end
+
+ def start_batch!
+ batch.update!(status_event: 'start', objects_count: 0, error: nil)
+ end
+
+ def finish_batch!
+ batch.update!(status_event: 'finish', objects_count: exported_objects_count, error: nil)
+ end
+
+ def fail_batch!(exception)
+ Gitlab::ErrorTracking.track_exception(exception, portable_id: portable.id, portable_type: portable.class.name)
+
+ batch.update!(status_event: 'fail_op', error: exception.message.truncate(255))
+ end
+ end
+end
diff --git a/app/services/bulk_imports/relation_export_service.rb b/app/services/bulk_imports/relation_export_service.rb
index b1efa881180..142bc48efe3 100644
--- a/app/services/bulk_imports/relation_export_service.rb
+++ b/app/services/bulk_imports/relation_export_service.rb
@@ -22,36 +22,27 @@ module BulkImports
upload_compressed_file(export)
end
ensure
- FileUtils.remove_entry(config.export_path)
+ FileUtils.remove_entry(export_path)
end
private
attr_reader :user, :portable, :relation, :jid, :config
- def find_or_create_export!
- validate_user_permissions!
+ delegate :export_path, to: :config
+ def find_or_create_export!
export = portable.bulk_import_exports.safe_find_or_create_by!(relation: relation)
- return export if export.finished? && export.updated_at > EXISTING_EXPORT_TTL.ago
+ return export if export.finished? && export.updated_at > EXISTING_EXPORT_TTL.ago && !export.batched?
- export.update!(status_event: 'start', jid: jid)
+ start_export!(export)
yield export
- export.update!(status_event: 'finish', error: nil)
+ finish_export!(export)
rescue StandardError => e
- Gitlab::ErrorTracking.track_exception(e, portable_id: portable.id, portable_type: portable.class.name)
-
- export&.update(status_event: 'fail_op', error: e.class)
- end
-
- def validate_user_permissions!
- ability = "admin_#{portable.to_ability_name}"
-
- user.can?(ability, portable) ||
- raise(::Gitlab::ImportExport::Error.permission_error(user, portable))
+ fail_export!(export, e)
end
def remove_existing_export_file!(export)
@@ -65,16 +56,16 @@ module BulkImports
def export_service
@export_service ||= if config.tree_relation?(relation) || config.self_relation?(relation)
- TreeExportService.new(portable, config.export_path, relation, user)
+ TreeExportService.new(portable, export_path, relation, user)
elsif config.file_relation?(relation)
- FileExportService.new(portable, config.export_path, relation)
+ FileExportService.new(portable, export_path, relation, user)
else
raise BulkImports::Error, 'Unsupported export relation'
end
end
def upload_compressed_file(export)
- compressed_file = File.join(config.export_path, "#{export_service.exported_filename}.gz")
+ compressed_file = File.join(export_path, "#{export_service.exported_filename}.gz")
upload = ExportUpload.find_or_initialize_by(export_id: export.id) # rubocop: disable CodeReuse/ActiveRecord
@@ -84,7 +75,30 @@ module BulkImports
end
def compress_exported_relation
- gzip(dir: config.export_path, filename: export_service.exported_filename)
+ gzip(dir: export_path, filename: export_service.exported_filename)
+ end
+
+ def start_export!(export)
+ export.update!(
+ status_event: 'start',
+ jid: jid,
+ batched: false,
+ batches_count: 0,
+ total_objects_count: 0,
+ error: nil
+ )
+
+ export.batches.destroy_all if export.batches.any? # rubocop:disable Cop/DestroyAll
+ end
+
+ def finish_export!(export)
+ export.update!(status_event: 'finish', batched: false, error: nil)
+ end
+
+ def fail_export!(export, exception)
+ Gitlab::ErrorTracking.track_exception(exception, portable_id: portable.id, portable_type: portable.class.name)
+
+ export&.update(status_event: 'fail_op', error: exception.class, batched: false)
end
end
end
diff --git a/app/services/bulk_imports/repository_bundle_export_service.rb b/app/services/bulk_imports/repository_bundle_export_service.rb
index 86159f5189d..441cced2f7f 100644
--- a/app/services/bulk_imports/repository_bundle_export_service.rb
+++ b/app/services/bulk_imports/repository_bundle_export_service.rb
@@ -8,7 +8,7 @@ module BulkImports
@export_filename = export_filename
end
- def execute
+ def execute(_options = {})
return unless repository_exists?
repository.bundle_to_disk(bundle_filepath)
diff --git a/app/services/bulk_imports/tree_export_service.rb b/app/services/bulk_imports/tree_export_service.rb
index b6f094da558..0aad271f40f 100644
--- a/app/services/bulk_imports/tree_export_service.rb
+++ b/app/services/bulk_imports/tree_export_service.rb
@@ -2,6 +2,10 @@
module BulkImports
class TreeExportService
+ include Gitlab::Utils::StrongMemoize
+
+ delegate :exported_objects_count, to: :serializer
+
def initialize(portable, export_path, relation, user)
@portable = portable
@export_path = export_path
@@ -11,43 +15,52 @@ module BulkImports
end
def execute
- return serializer.serialize_root(config.class::SELF_RELATION) if self_relation?
-
- relation_definition = config.tree_relation_definition_for(relation)
-
- raise BulkImports::Error, 'Unsupported relation export type' unless relation_definition
+ if self_relation?(relation)
+ serializer.serialize_root(config.class::SELF_RELATION)
+ else
+ serializer.serialize_relation(relation_definition)
+ end
+ end
- serializer.serialize_relation(relation_definition)
+ def export_batch(ids)
+ serializer.serialize_relation(relation_definition, batch_ids: Array.wrap(ids))
end
def exported_filename
- return "#{relation}.json" if self_relation?
-
- "#{relation}.ndjson"
+ "#{relation}.#{extension}"
end
private
+ delegate :self_relation?, to: :config
+
attr_reader :export_path, :portable, :relation, :config, :user
# rubocop: disable CodeReuse/Serializer
def serializer
- ::Gitlab::ImportExport::Json::StreamingSerializer.new(
+ @serializer ||= ::Gitlab::ImportExport::Json::StreamingSerializer.new(
portable,
config.portable_tree,
- json_writer,
+ ::Gitlab::ImportExport::Json::NdjsonWriter.new(export_path),
exportable_path: '',
current_user: user
)
end
# rubocop: enable CodeReuse/Serializer
- def json_writer
- ::Gitlab::ImportExport::Json::NdjsonWriter.new(export_path)
+ def extension
+ return 'json' if self_relation?(relation)
+
+ 'ndjson'
end
- def self_relation?
- relation == config.class::SELF_RELATION
+ def relation_definition
+ definition = config.tree_relation_definition_for(relation)
+
+ raise BulkImports::Error, 'Unsupported relation export type' unless definition
+
+ definition
end
+ strong_memoize_attr :relation_definition
end
end
diff --git a/app/services/bulk_imports/uploads_export_service.rb b/app/services/bulk_imports/uploads_export_service.rb
index 315590bea31..4d55f159af4 100644
--- a/app/services/bulk_imports/uploads_export_service.rb
+++ b/app/services/bulk_imports/uploads_export_service.rb
@@ -7,13 +7,22 @@ module BulkImports
BATCH_SIZE = 100
AVATAR_PATH = 'avatar'
+ attr_reader :exported_objects_count
+
def initialize(portable, export_path)
@portable = portable
@export_path = export_path
+ @exported_objects_count = 0
end
- def execute
- portable.uploads.find_each(batch_size: BATCH_SIZE) do |upload| # rubocop: disable CodeReuse/ActiveRecord
+ def execute(options = {})
+ relation = portable.uploads
+
+ if options[:batch_ids]
+ relation = relation.where(relation.model.primary_key => options[:batch_ids]) # rubocop:disable CodeReuse/ActiveRecord
+ end
+
+ relation.find_each(batch_size: BATCH_SIZE) do |upload| # rubocop: disable CodeReuse/ActiveRecord
uploader = upload.retrieve_uploader
next unless upload.exist?
@@ -22,6 +31,7 @@ module BulkImports
subdir_path = export_subdir_path(upload)
mkdir_p(subdir_path)
download_or_copy_upload(uploader, File.join(subdir_path, uploader.filename))
+ @exported_objects_count += 1
rescue StandardError => e
# Do not fail entire project export if something goes wrong during file download
# (e.g. downloaded file has filename that exceeds 255 characters).
diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb
index 4c087d23a53..1094a131e68 100644
--- a/app/services/ci/pipeline_processing/atomic_processing_service.rb
+++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb
@@ -19,9 +19,10 @@ module Ci
def execute
return unless pipeline.needs_processing?
+ # Run the process only if we can obtain an exclusive lease; returns nil if lease is unavailable
success = try_obtain_lease { process! }
- # re-schedule if we need further processing
+ # Re-schedule if we need further processing
if success && pipeline.needs_processing?
PipelineProcessWorker.perform_async(pipeline.id)
end
diff --git a/app/services/system_notes/time_tracking_service.rb b/app/services/system_notes/time_tracking_service.rb
index c5bdbc6799e..b7a2afbaf15 100644
--- a/app/services/system_notes/time_tracking_service.rb
+++ b/app/services/system_notes/time_tracking_service.rb
@@ -147,7 +147,7 @@ module SystemNotes
readable_date = date_key.humanize.downcase
if changed_date.nil?
- "removed #{readable_date}"
+ "removed #{readable_date} #{changed_dates[date_key].first.to_s(:long)}"
else
"changed #{readable_date} to #{changed_date.to_s(:long)}"
end
diff --git a/app/views/shared/dashboard/_no_filter_selected.html.haml b/app/views/dashboard/_no_filter_selected.html.haml
index 48c844d93e8..48c844d93e8 100644
--- a/app/views/shared/dashboard/_no_filter_selected.html.haml
+++ b/app/views/dashboard/_no_filter_selected.html.haml
diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml
index 7e77b31499a..78c3270114e 100644
--- a/app/views/dashboard/issues.html.haml
+++ b/app/views/dashboard/issues.html.haml
@@ -7,24 +7,11 @@
= render_dashboard_ultimate_trial(current_user)
-.page-title-holder.d-flex.align-items-center
+.page-title-holder.gl-display-flex.gl-align-items-center
%h1.page-title.gl-font-size-h-display= _('Issues')
- if current_user
.page-title-controls
= render 'shared/new_project_item_vue_select'
-- if ::Feature.enabled?(:vue_issues_dashboard)
- .js-issues-dashboard{ data: dashboard_issues_list_data(current_user) }
-- else
- .top-area
- = render 'shared/issuable/nav', type: :issues, display_count: !@no_filters_set
- .nav-controls
- = render 'shared/issuable/feed_buttons'
-
- = render 'shared/issuable/search_bar', type: :issues
-
- - if current_user && @no_filters_set
- = render 'shared/dashboard/no_filter_selected'
- - else
- = render 'shared/issues'
+.js-issues-dashboard{ data: dashboard_issues_list_data(current_user) }
diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml
index eb4ce46412b..de34c709ff3 100644
--- a/app/views/dashboard/merge_requests.html.haml
+++ b/app/views/dashboard/merge_requests.html.haml
@@ -17,7 +17,7 @@
= render 'shared/issuable/search_bar', type: :merge_requests, disable_target_branch: true
- if current_user && @no_filters_set
- = render 'shared/dashboard/no_filter_selected'
+ = render 'no_filter_selected'
- elsif @search_timeout_occurred
= render 'shared/dashboard/search_timeout_occurred'
- else
diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml
index 214b41d5ab6..31d02324e68 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -10,7 +10,6 @@
- content_for :flash_message do
= dispensable_render_if_exists "projects/storage_enforcement_alert", context: @project
= dispensable_render_if_exists "shared/namespace_storage_limit_alert", context: @project
- = dispensable_render_if_exists "projects/deprecate_license_check_alert", project: @project
- content_for :project_javascripts do
- project = @target_project || @project
diff --git a/app/views/projects/issues/_related_issues.html.haml b/app/views/projects/issues/_related_issues.html.haml
index 80f2b8b189c..2409c61fbf2 100644
--- a/app/views/projects/issues/_related_issues.html.haml
+++ b/app/views/projects/issues/_related_issues.html.haml
@@ -5,4 +5,5 @@
has_issue_weights_feature: @project.licensed_feature_available?(:issue_weights).to_s,
help_path: help_page_path('user/project/issues/related_issues'),
show_categorized_issues: @project.licensed_feature_available?(:blocked_issues).to_s,
- has_iterations_feature: @project.licensed_feature_available?(:iterations).to_s } }
+ has_iterations_feature: @project.licensed_feature_available?(:iterations).to_s,
+ report_abuse_path: add_category_abuse_reports_path } }
diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml
index 4f6983c6fe3..0eef3eeee3c 100644
--- a/app/views/projects/merge_requests/_widget.html.haml
+++ b/app/views/projects/merge_requests/_widget.html.haml
@@ -11,7 +11,7 @@
window.gl.mrWidgetData.ci_troubleshooting_docs_path = '#{help_page_path('ci/troubleshooting.md')}';
window.gl.mrWidgetData.mr_troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/reviews/index.md', anchor: 'troubleshooting')}';
window.gl.mrWidgetData.pipeline_must_succeed_docs_path = '#{help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds.md', anchor: 'require-a-successful-pipeline-for-merge')}';
- window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.md', anchor: 'security-approvals-in-merge-requests')}';
+ window.gl.mrWidgetData.code_coverage_check_help_page_path = '#{help_page_path('ci/testing/code_coverage.md', anchor: 'coverage-check-approval-rule')}';
window.gl.mrWidgetData.security_configuration_path = '#{project_security_configuration_path(@project)}';
window.gl.mrWidgetData.license_compliance_docs_path = '#{help_page_path('user/compliance/license_compliance/index.md', anchor: 'policies')}';
window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/approvals/rules.md', anchor: 'eligible-approvers')}';
diff --git a/app/views/shared/_issues.html.haml b/app/views/shared/_issues.html.haml
deleted file mode 100644
index 360a3f3eb89..00000000000
--- a/app/views/shared/_issues.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-= render 'shared/alerts/positioning_disabled' if @sort == 'relative_position'
-
-- if @issues.to_a.any?
- %ul.content-list.issues-list.issuable-list{ class: issue_manual_ordering_class }
- = render partial: 'projects/issues/issue', collection: @issues
- = paginate @issues, theme: "gitlab"
-- else
- = render 'shared/empty_states/issues'
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index 7f2511d3e28..a116f69363d 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -18,26 +18,6 @@
= render Pajamas::ButtonComponent.new(category: :tertiary,
icon: 'star-o',
button_options: { class: 'add-priority has-tooltip', title: _('Prioritize'), aria_label: _('Prioritize label'), data: { placement: 'bottom' } })
- - if can?(current_user, :admin_label, label)
- %li.gl-display-inline-block
- = render Pajamas::ButtonComponent.new(href: label.edit_path, category: :tertiary, icon: 'pencil', button_options: { class: 'edit has-tooltip', 'title': _('Edit'), 'aria_label': _('Edit'), data: { placement: 'bottom' } })
- - if can?(current_user, :admin_label, label)
- %li.gl-display-inline-block
- .dropdown
- = render Pajamas::ButtonComponent.new(category: :tertiary,
- icon: 'ellipsis_v',
- button_options: { class: 'js-label-options-dropdown', 'aria_label': _('Label actions dropdown'), data: { toggle: 'dropdown' } })
- .dropdown-menu.dropdown-open-left
- %ul
- - if label.project_label? && label.project.group && can?(current_user, :admin_label, label.project.group)
- %li
- = render Pajamas::ButtonComponent.new(category: :tertiary, variant: :link,
- button_options: { class: 'js-promote-project-label-button', data: { url: promote_project_label_path(label.project, label), label_title: label.title, label_color: label.color, label_text_color: label.text_color, group_name: label.project.group.name } }) do
- = _('Promote to group label')
- %li
- = render Pajamas::ButtonComponent.new(category: :tertiary, variant: :link,
- button_options: { class: 'text-danger js-delete-label-modal-button', data: { label_name: label.name, subject_name: label.subject_name, destroy_path: label.destroy_path } }) do
- = _('Delete')
- if current_user
%li.gl-display-inline-block.label-subscription.js-label-subscription.gl-ml-3
- if label.can_subscribe_to_label_in_different_levels?
@@ -58,3 +38,23 @@
- else
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-subscribe-button gl-w-full', data: { status: status, url: toggle_subscription_path, toggle: 'tooltip', container: 'body' }, title: tooltip_title }) do
= label_subscription_toggle_button_text(label, @project)
+ - if can?(current_user, :admin_label, label)
+ %li.gl-display-inline-block
+ .dropdown
+ = render Pajamas::ButtonComponent.new(category: :tertiary,
+ icon: 'ellipsis_v',
+ button_options: { class: 'js-label-options-dropdown gl-ml-3', 'aria_label': _('Label actions dropdown'), title: _('Label actions dropdown'), data: { toggle: 'dropdown' } })
+ .dropdown-menu.dropdown-menu-right
+ %ul
+ %li
+ = render Pajamas::ButtonComponent.new(category: :tertiary, href: label.edit_path, variant: :link) do
+ = _('Edit')
+ - if label.project_label? && label.project.group && can?(current_user, :admin_label, label.project.group)
+ %li
+ = render Pajamas::ButtonComponent.new(category: :tertiary, variant: :link,
+ button_options: { class: 'js-promote-project-label-button', data: { url: promote_project_label_path(label.project, label), label_title: label.title, label_color: label.color, label_text_color: label.text_color, group_name: label.project.group.name } }) do
+ = _('Promote to group label')
+ %li
+ = render Pajamas::ButtonComponent.new(category: :tertiary, variant: :link,
+ button_options: { class: 'text-danger js-delete-label-modal-button', data: { label_name: label.name, subject_name: label.subject_name, destroy_path: label.destroy_path } }) do
+ = _('Delete')
diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml
index cc806044374..899b2ed832e 100644
--- a/app/views/shared/labels/_form.html.haml
+++ b/app/views/shared/labels/_form.html.haml
@@ -9,7 +9,7 @@
.form-group.row
.col-12
- = f.label :description
+ = f.label :description, _("Description (optional)")
= f.text_area :description, class: "gl-form-input form-control js-quick-submit", rows: 4, data: { qa_selector: 'label_description_field' }
.form-group.row
.col-12
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 582f5037e48..446cba267ad 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -2316,6 +2316,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: bulk_imports_finish_batched_relation_export
+ :worker_name: BulkImports::FinishBatchedRelationExportWorker
+ :feature_category: :importers
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: bulk_imports_pipeline
:worker_name: BulkImports::PipelineWorker
:feature_category: :importers
@@ -2325,6 +2334,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: bulk_imports_relation_batch_export
+ :worker_name: BulkImports::RelationBatchExportWorker
+ :feature_category: :importers
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: bulk_imports_relation_export
:worker_name: BulkImports::RelationExportWorker
:feature_category: :importers
diff --git a/app/workers/bulk_imports/finish_batched_relation_export_worker.rb b/app/workers/bulk_imports/finish_batched_relation_export_worker.rb
new file mode 100644
index 00000000000..aa7bbffa732
--- /dev/null
+++ b/app/workers/bulk_imports/finish_batched_relation_export_worker.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+module BulkImports
+ class FinishBatchedRelationExportWorker
+ include ApplicationWorker
+
+ idempotent!
+ data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency
+ feature_category :importers
+
+ REENQUEUE_DELAY = 5.seconds
+ TIMEOUT = 6.hours
+
+ def perform(export_id)
+ @export = Export.find_by_id(export_id)
+
+ return unless export
+ return if export.finished? || export.failed?
+ return re_enqueue if export_in_progress?
+ return fail_export! if export_timeout?
+
+ finish_export!
+ end
+
+ private
+
+ attr_reader :export
+
+ def fail_export!
+ expire_cache!
+
+ export.batches.map(&:fail_op!)
+ export.fail_op!
+ end
+
+ def re_enqueue
+ self.class.perform_in(REENQUEUE_DELAY.ago, export.id)
+ end
+
+ def export_timeout?
+ export.updated_at < TIMEOUT.ago
+ end
+
+ def export_in_progress?
+ export.batches.any?(&:started?)
+ end
+
+ def finish_export!
+ expire_cache!
+
+ export.finish!
+ end
+
+ def expire_cache!
+ export.batches.each do |batch|
+ key = BulkImports::BatchedRelationExportService.cache_key(export.id, batch.id)
+
+ Gitlab::Cache::Import::Caching.expire(key, 0)
+ end
+ end
+ end
+end
diff --git a/app/workers/bulk_imports/relation_batch_export_worker.rb b/app/workers/bulk_imports/relation_batch_export_worker.rb
new file mode 100644
index 00000000000..4ce36929e15
--- /dev/null
+++ b/app/workers/bulk_imports/relation_batch_export_worker.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module BulkImports
+ class RelationBatchExportWorker
+ include ApplicationWorker
+
+ idempotent!
+ data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency
+ feature_category :importers
+ sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION
+
+ def perform(user_id, batch_id)
+ RelationBatchExportService.new(user_id, batch_id).execute
+ end
+ end
+end
diff --git a/app/workers/bulk_imports/relation_export_worker.rb b/app/workers/bulk_imports/relation_export_worker.rb
index 9d1ed30caf6..531edc6c7a7 100644
--- a/app/workers/bulk_imports/relation_export_worker.rb
+++ b/app/workers/bulk_imports/relation_export_worker.rb
@@ -13,11 +13,16 @@ module BulkImports
sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION
worker_resource_boundary :memory
- def perform(user_id, portable_id, portable_class, relation)
+ def perform(user_id, portable_id, portable_class, relation, batched = false)
user = User.find(user_id)
portable = portable(portable_id, portable_class)
+ config = BulkImports::FileTransfer.config_for(portable)
- RelationExportService.new(user, portable, relation, jid).execute
+ if Gitlab::Utils.to_boolean(batched) && config.batchable_relation?(relation)
+ BatchedRelationExportService.new(user, portable, relation, jid).execute
+ else
+ RelationExportService.new(user, portable, relation, jid).execute
+ end
end
private
diff --git a/app/workers/pipeline_process_worker.rb b/app/workers/pipeline_process_worker.rb
index b4712aaeafb..caa2591f1ba 100644
--- a/app/workers/pipeline_process_worker.rb
+++ b/app/workers/pipeline_process_worker.rb
@@ -14,7 +14,7 @@ class PipelineProcessWorker
loggable_arguments 1
idempotent!
- deduplicate :until_executing
+ deduplicate :until_executing # Remove when FF `ci_pipeline_process_worker_dedup_until_executed` is removed
def perform(pipeline_id)
Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline|
@@ -23,4 +23,14 @@ class PipelineProcessWorker
.execute
end
end
+
+ # When FF `ci_pipeline_process_worker_dedup_until_executed` is removed, remove this method and
+ # add `deduplicate :until_executed, if_deduplicated: :reschedule_once`, ttl: 1.minute to the class
+ def self.perform_async(pipeline_id)
+ return super unless Feature.enabled?(:ci_pipeline_process_worker_dedup_until_executed)
+
+ set(
+ deduplicate: { strategy: :until_executed, options: { if_deduplicated: :reschedule_once, ttl: 1.minute } }
+ ).perform_async(pipeline_id)
+ end
end
diff --git a/config/feature_flags/development/ci_pipeline_process_worker_dedup_until_executed.yml b/config/feature_flags/development/ci_pipeline_process_worker_dedup_until_executed.yml
new file mode 100644
index 00000000000..34882adcc87
--- /dev/null
+++ b/config/feature_flags/development/ci_pipeline_process_worker_dedup_until_executed.yml
@@ -0,0 +1,8 @@
+---
+name: ci_pipeline_process_worker_dedup_until_executed
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/115261
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/397829
+milestone: '15.11'
+type: development
+group: group::pipeline authoring
+default_enabled: false
diff --git a/config/feature_flags/development/vue_issues_dashboard.yml b/config/feature_flags/development/vue_issues_dashboard.yml
deleted file mode 100644
index 3091cf8b8b4..00000000000
--- a/config/feature_flags/development/vue_issues_dashboard.yml
+++ /dev/null
@@ -1,8 +0,0 @@
----
-name: vue_issues_dashboard
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/102197
-rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/379025
-milestone: '15.6'
-type: development
-group: group::project management
-default_enabled: true
diff --git a/config/initializers/active_record_transaction_observer.rb b/config/initializers/active_record_transaction_observer.rb
index 1dc7b55ed5a..a133c6b8be8 100644
--- a/config/initializers/active_record_transaction_observer.rb
+++ b/config/initializers/active_record_transaction_observer.rb
@@ -3,7 +3,20 @@
return unless Gitlab.com? || Gitlab.dev_or_test_env?
Gitlab::Application.configure do
- if Feature.feature_flags_available? && ::Feature.enabled?(:active_record_transactions_tracking, type: :ops)
+ # When the DBMS is not available, an exception (e.g. PG::ConnectionBad) is raised
+ active_db_connection = begin
+ ActiveRecord::Base.connection.active? # rubocop:disable Database/MultipleDatabases
+ rescue StandardError
+ false
+ end
+
+ feature_flags_available = begin
+ active_db_connection && Feature::FlipperFeature.table_exists?
+ rescue ActiveRecord::NoDatabaseError
+ false
+ end
+
+ if feature_flags_available && ::Feature.enabled?(:active_record_transactions_tracking, type: :ops)
Gitlab::Database::Transaction::Observer.register!
end
end
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index 2f66e4d25dc..e4c9c09cd39 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -81,8 +81,12 @@
- 1
- - bulk_imports_export_request
- 1
+- - bulk_imports_finish_batched_relation_export
+ - 1
- - bulk_imports_pipeline
- 1
+- - bulk_imports_relation_batch_export
+ - 1
- - bulk_imports_relation_export
- 1
- - chaos
diff --git a/doc/integration/jira/issues.md b/doc/integration/jira/issues.md
index 9f8f98bfa5e..e7802a5fde2 100644
--- a/doc/integration/jira/issues.md
+++ b/doc/integration/jira/issues.md
@@ -71,7 +71,7 @@ You can configure custom rules for how GitLab matches Jira issue keys by definin
- [A regex pattern](#use-regular-expression)
- [A prefix](#use-a-prefix)
-When you don't configure custom rules, the [default behavior](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/regex.rb#L509) is used. For more information, see the [RE2 wiki](https://github.com/google/re2/wiki/Syntax).
+When you don't configure custom rules, the [default behavior](https://gitlab.com/gitlab-org/gitlab/-/blob/710d83af298d8896f2b940faf48a46d2feb4cbaf/lib/gitlab/regex.rb#L552) is used. For more information, see the [RE2 wiki](https://github.com/google/re2/wiki/Syntax).
### Use regular expression
diff --git a/doc/tutorials/boards_for_teams/index.md b/doc/tutorials/boards_for_teams/index.md
new file mode 100644
index 00000000000..e37bf5a2d31
--- /dev/null
+++ b/doc/tutorials/boards_for_teams/index.md
@@ -0,0 +1,202 @@
+---
+stage: Plan
+group: Project Management
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments
+---
+
+# Tutorial: Set up issue boards for team hand-off **(PREMIUM)**
+
+<!-- vale gitlab.FutureTense = NO -->
+
+This tutorial shows you how to set up [issue boards](../../user/project/issue_board.md) and [scoped labels](../../user/project/labels.md#scoped-labels) for two teams that work on issues in sequence.
+
+In this example, you'll create two issue boards for the UX and Frontend teams.
+Using the following steps, you can create issue boards and workflows for more sub-teams, like Backend
+or Quality Assurance.
+To learn how we use workflow labels at GitLab, see [Product Development Flow](https://about.gitlab.com/handbook/product-development-flow).
+
+To set up issue boards for multiple teams:
+
+1. [Create a group](#create-a-group)
+1. [Create a project](#create-a-project)
+1. [Create labels](#create-labels)
+1. [Create team issue boards](#create-team-issue-boards)
+1. [Create issues for features](#create-issues-for-features)
+
+## The goal workflow
+
+After you set up everything, the two teams will be able to hand off issues from one board to another, for example, like this:
+
+1. The project lead adds the `Workflow::Ready for design` and `Frontend` labels to a feature issue called **Redesign user profile page**.
+1. A product designer on the UX team:
+ 1. Checks the `Workflow::Ready for design` list on the **UX workflow** board and decides to work on the profile page redesign.
+
+ <!-- Image: UX board with lists:
+ ~Workflow::Ready for design,
+ ~Workflow::Design
+ ~Workflow::Ready for development -->
+
+ 1. Assigns themselves to the issue.
+ 1. Drags the issue card to the `Workflow::Design` list. The previous workflow label is automatically removed.
+ 1. Creates the ✨new designs✨.
+ 1. [Adds the designs to the issue](../../user/project/issues/design_management.md).
+ 1. Drags the issue card to the `Workflow::Ready for development` list, which adds this label and removes any other `Workflow::` label.
+ 1. Unassigns themselves from the issue.
+1. A developer on the Frontend team:
+ 1. Checks the `Workflow::Ready for development` list on the **Frontend workflow** board and chooses an issue to work on.
+
+ <!-- Image: Frontend board, scoped to ~Frontend, with lists:
+ ~Workflow::Ready for development
+ ~Workflow::In development
+ ~Workflow::Complete -->
+
+ 1. Assigns themselves to the issue.
+ 1. Drags the issue card to the `Workflow::In development` list. The previous workflow label is automatically removed.
+ 1. Adds the frontend code in a [merge request](../../user/project/merge_requests/index.md).
+ 1. Adds the `Workflow::Complete` label.
+
+## Create a group
+
+To prepare for when your project grows, start by creating a group.
+You use groups to manage one or more related projects at the same time.
+You add your users as members in the group, and assign them a role.
+
+Prerequisites:
+
+- If you're using an existing group for this tutorial, make sure you have at least the Reporter role
+ for the group.
+
+To create a group:
+
+1. On the top bar, select **Create new... > New group**.
+1. Select **Create group**.
+1. Complete the fields. Name your group `Paperclip Software Factory`.
+1. Select **Create group**.
+
+You've created an empty group. Next, you'll create a project that will store your issues and code.
+
+## Create a project
+
+The main code development work happens in projects and their repositories.
+A project contains your code and pipelines, but also the issues that are used for planning your
+upcoming code changes.
+
+Prerequisites:
+
+- If you're using an existing project for this tutorial, make sure you have at least the Reporter role
+ for the project.
+
+To create a blank project:
+
+1. In your group, on the right of the page, select **New project**.
+1. Select **Create blank project**.
+1. Enter the project details:
+ - In the **Project name** field, name your project `Paperclip Assistant`.
+1. Select **Create project**.
+
+## Create labels
+
+You need a team label and a set of workflow labels to show where in the development cycle an issue is.
+
+You could create these labels in your `Paperclip Assistant` project, but it's better to create them
+in the `Paperclip Software Factory` group. This way, these labels will also be available in all the other
+projects you create later.
+
+To create each label:
+
+1. On the top bar, select **Main menu > Group** and find your **Paperclip Software Factory** group.
+1. On the left sidebar, select **Group information > Labels**.
+1. Select **New label**.
+1. In the **Title** field, enter the name of the label. Start with `Frontend`.
+1. Optional. Select a color by selecting from the available colors, or enter a hex color value for
+ a specific color in the **Background color** field.
+1. Select **Create label**.
+
+Repeat these steps to create all the labels you'll need:
+
+- `Frontend`
+- `Workflow::Ready for design`
+- `Workflow::Design`
+- `Workflow::Ready for development`
+- `Workflow::In development`
+- `Workflow::Complete`
+
+## Create team issue boards
+
+Like with labels, you could create your issue boards in the **Paperclip Assistant** project,
+but it can be better to have them in the **Paperclip Software Factory** group. This way, you'll be able
+to manage issues from all the projects that you might create later in this group.
+
+To create a new group issue board:
+
+1. On the top bar, select **Main menu > Group** and find your **Paperclip Software Factory** group.
+1. On the left sidebar, select **Issues > Boards**.
+1. Create the UX workflow and Frontend workflow boards.
+
+To create the **UX workflow** issue board:
+
+1. In the upper-left corner of the issue board page, select the dropdown list with the current board name.
+1. Select **Create new board**.
+1. In the **Title field**, enter `UX workflow`.
+1. Clear the **Show the Open list** and **Show the Closed list** checkboxes.
+1. Select **Create board**. You should see an empty board.
+
+ <!-- Image: empty UX workflow board -->
+
+1. Create a list for the `Workflow::Ready for design` label:
+ 1. In the upper-left corner of the issue board page, select **Create list**.
+ 1. In the column that appears, from the **Value** dropdown list, select the `Workflow::Ready for design` label.
+ 1. Select **Add to board**.
+1. Repeat the previous step for labels `Workflow::Design` and `Workflow::Ready for development`.
+
+To create the **Frontend workflow** board:
+
+1. In the upper-left corner of the issue board page, select the dropdown list with the current board name.
+1. Select **Create new board**.
+1. In the **Title field**, enter `Frontend workflow`.
+1. Clear the **Show the Open list** and **Show the Closed list** checkboxes.
+1. Expand **Scope**.
+1. Next to **Labels**, select **Edit** and select the `Frontend` label.
+1. Select **Create board**.
+1. Create a list for the `Workflow::Ready for development` label:
+ 1. In the upper-left corner of the issue board page, select **Create list**.
+ 1. In the column that appeared, from the **Value** dropdown list, select the `Workflow::Ready for development` label.
+ 1. Select **Add to board**.
+1. Repeat the previous step for labels `Workflow::In development` and `Workflow::Complete`.
+
+For now, lists in both your boards should be empty. Next, you'll populate them with some issues.
+
+## Create issues for features
+
+To track upcoming features, enhancements, and bugs, you must create some issues.
+Issues belong in projects, but you can also create them directly from your issue board.
+
+To create an issue from your board:
+
+1. In the upper-left corner of the issue board page, select the dropdown list with the current board name.
+1. Select **UX workflow**.
+1. On the `Workflow::Ready for development` list, select **List actions** (**{ellipsis_v}**) **> Create new issue**.
+1. Complete the fields:
+ 1. Under **Title**, enter `Redesign user profile page`.
+ 1. Under **Projects**, select **Paperclip Software Factory / Paperclip Assistant**.
+1. Select **Create issue**. Because you created the new issue in the label list, it gets created
+ with this label.
+1. Add the `Frontend` label, because only issues with this label appear on the Frontend team's board:
+ 1. Select the issue card (not its title), and a sidebar appears on the right.
+ 1. In the **Labels** section of the sidebar, select **Edit**.
+ 1. From the **Assign labels** dropdown list, select the `Workflow::Ready for design` and
+ `Frontend` labels. The selected labels are marked with a checkmark.
+ 1. To apply your changes to labels, select **X** next to **Assign labels** or select any area
+ outside the label section.
+
+Repeat these steps to create a few more issues with the same labels.
+
+You should now see at least one issue there, ready for your product designers to start working on!
+
+<!-- Image: UX workflow board with at least one issue in the `Workflow::Ready for design` list -->
+
+Congratulations! Now your teams can start collaborating on amazing software.
+
+## Learn more about project management in GitLab
+
+Find other tutorials about project management on the [tutorials page](../plan_and_track.md).
diff --git a/doc/user/application_security/index.md b/doc/user/application_security/index.md
index 32a4b0f4318..38625a12c25 100644
--- a/doc/user/application_security/index.md
+++ b/doc/user/application_security/index.md
@@ -274,13 +274,12 @@ By default, the vulnerability report does not show vulnerabilities of `dismissed
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/9928) in GitLab 12.2.
> - [Removed](https://gitlab.com/gitlab-org/gitlab/-/issues/357300) the Vulnerability-Check feature in GitLab 15.0.
+> - [Removed](https://gitlab.com/gitlab-org/gitlab/-/issues/397067) the License-Check feature in GitLab 16.0.
You can enforce an additional approval for merge requests that would introduce one of the following
security issues:
- A security vulnerability. For more details, read [Scan result policies](policies/scan-result-policies.md).
-- A software license compliance violation. For more details, read
- [Enabling license approvals within a project](../compliance/license_check_rules.md#enabling-license-approvals-within-a-project).
## Using private Maven repositories
diff --git a/doc/user/application_security/secret_detection/index.md b/doc/user/application_security/secret_detection/index.md
index b63db28bfc6..78de8df5cb9 100644
--- a/doc/user/application_security/secret_detection/index.md
+++ b/doc/user/application_security/secret_detection/index.md
@@ -40,7 +40,7 @@ contains more than 100 patterns.
Most Secret Detection patterns search for specific types of secrets.
Many services add prefixes or other structural details to their secrets so they can be identified if they're leaked.
-For example, GitLab [adds a `glpat-` prefix](../../admin_area/settings/account_and_limit_settings.md#personal-access-token-prefix) to project, group, and project access tokens by default.
+For example, GitLab [adds a `glpat-` prefix](../../admin_area/settings/account_and_limit_settings.md#personal-access-token-prefix) to project, group, and personal access tokens by default.
To provide more reliable, high-confidence results, Secret Detection only looks for passwords or other unstructured secrets in specific contexts like URLs.
diff --git a/doc/user/compliance/img/license-check_v13_4.png b/doc/user/compliance/img/license-check_v13_4.png
deleted file mode 100644
index bc80f938395..00000000000
--- a/doc/user/compliance/img/license-check_v13_4.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/compliance/img/policies_maintainer_add_v14_3.png b/doc/user/compliance/img/policies_maintainer_add_v14_3.png
deleted file mode 100644
index 7a27899f8c9..00000000000
--- a/doc/user/compliance/img/policies_maintainer_add_v14_3.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/compliance/img/policies_maintainer_edit_v14_3.png b/doc/user/compliance/img/policies_maintainer_edit_v14_3.png
deleted file mode 100644
index 256c66bf7d8..00000000000
--- a/doc/user/compliance/img/policies_maintainer_edit_v14_3.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/compliance/img/policies_v13_0.png b/doc/user/compliance/img/policies_v13_0.png
deleted file mode 100644
index 4918a0e6b62..00000000000
--- a/doc/user/compliance/img/policies_v13_0.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/compliance/license_check_rules.md b/doc/user/compliance/license_check_rules.md
deleted file mode 100644
index 4280cfa0f5b..00000000000
--- a/doc/user/compliance/license_check_rules.md
+++ /dev/null
@@ -1,84 +0,0 @@
----
-type: reference, howto
-stage: Govern
-group: Security Policies
-info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments
----
-
-# License Check Policies (deprecated) **(ULTIMATE)**
-
-> [Deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/390417) in GitLab 15.9.
-
-WARNING:
-This feature was [deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/390417) in GitLab 15.9. Users should migrate over to use [License Approval Policies](license_approval_policies.md) prior to GitLab 16.0.
-
-License check policies allow you to specify licenses that are `allowed` or `denied` in a project. If a `denied`
-license is newly committed it blocks the merge request and instructs the developer to remove it.
-Note, the merge request is not able to be merged until the `denied` license is removed.
-You may add a [`License-Check` approval rule](#enabling-license-approvals-within-a-project),
-which enables a designated approver that can approve and then merge a merge request with `denied` license.
-
-These policies can be configured by using the [Managed Licenses API](../../api/managed_licenses.md).
-
-![Merge request with denied licenses](img/denied_licenses_v15_3.png)
-
-The **Policies** tab in the project's license compliance section displays your project's license
-policies. Project maintainers can specify policies in this section.
-
-![Edit Policy](img/policies_maintainer_edit_v14_3.png)
-
-![Add Policy](img/policies_maintainer_add_v14_3.png)
-
-Developers of the project can view the policies configured in a project.
-
-![View Policies](img/policies_v13_0.png)
-
-## Enabling License Approvals within a project
-
-Prerequisites:
-
-- Maintainer or Owner role.
-
-`License-Check` is a [merge request approval](../project/merge_requests/approvals/index.md) rule
-you can enable to allow an individual or group to approve a merge request that contains a `denied`
-license.
-
-You can enable `License-Check` one of two ways:
-
-1. On the top bar, select **Main menu > Projects** and find your project.
-1. On the left sidebar, select **Settings > General**.
-1. Expand **Merge request approvals**.
-1. Select **Enable** or **Edit**.
-1. Add or change the **Rule name** to `License-Check` (case sensitive).
-
-![License Check Approver Rule](img/license-check_v13_4.png)
-
-- Create an approval group in the [project policies section for License Compliance](license_check_rules.md#license-check-policies-deprecated).
- You must set this approval group's number of approvals required to greater than zero. After you
- enable this group in your project, the approval rule is enabled for all merge requests.
-
-Any code changes cause the approvals required to reset.
-
-An approval is required when a license report:
-
-- Contains a dependency that includes a software license that is `denied`.
-- Is not generated during pipeline execution.
-
-An approval is optional when a license report:
-
-- Contains no software license violations.
-- Contains only new licenses that are `allowed` or unknown.
-
-## Troubleshooting
-
-### The License Compliance widget is stuck in a loading state
-
-A loading spinner is displayed in the following scenarios:
-
-- While the pipeline is in progress.
-- If the pipeline is complete, but still parsing the results in the background.
-- If the license scanning job is complete, but the pipeline is still running.
-
-The License Compliance widget polls every few seconds for updated results. When the pipeline is complete, the first poll after pipeline completion triggers the parsing of the results. This can take a few seconds depending on the size of the generated report.
-
-The final state is when a successful pipeline run has been completed, parsed, and the licenses displayed in the widget.
diff --git a/doc/user/compliance/license_compliance/index.md b/doc/user/compliance/license_compliance/index.md
index 8c6d5ee893f..4fae086be31 100644
--- a/doc/user/compliance/license_compliance/index.md
+++ b/doc/user/compliance/license_compliance/index.md
@@ -25,8 +25,8 @@ For the job to activate, License Finder needs to find a compatible package defin
GitLab checks the License Compliance report, compares the
licenses between the source and target branches, and shows the information right on the merge
request. Denied licenses are indicated by a `x` red icon next to them as well as new licenses that
-need a decision from you. In addition, you can [manually allow or deny](../license_check_rules.md) licenses in your
-project's license compliance policy section. If a denied license is detected in a new commit,
+need a decision from you. In addition, you can [manually allow or deny](../license_approval_policies.md) licenses in your
+project's security policies section. If a denied license is detected in a new commit,
GitLab blocks any merge requests containing that commit and instructs the developer to remove the
license.
@@ -85,7 +85,7 @@ dependencies to find their licenses.
GitLab has limited support for [composite licenses](https://spdx.github.io/spdx-spec/v2-draft/SPDX-license-expressions/).
License compliance can read multiple licenses, but always considers them combined using the `AND` operator. For example,
-if a dependency has two licenses, and one of them is allowed and the other is denied by the project [policy](../license_check_rules.md),
+if a dependency has two licenses, and one of them is allowed and the other is denied by the project [license approval policy](../license_approval_policies.md),
GitLab evaluates the composite license as _denied_, as this is the safer option.
The ability to support other license expression operators (like `OR`, `WITH`) is tracked
in [this epic](https://gitlab.com/groups/gitlab-org/-/epics/6571).
@@ -693,8 +693,8 @@ Additional configuration may be needed for connecting to private registries for:
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/212388) in GitLab 13.3.
-Prior to GitLab 13.3, offline environments required an exact name match for [project policies](../license_check_rules.md).
-In GitLab 13.3 and later, GitLab matches the name of [project policies](../license_check_rules.md)
+Prior to GitLab 13.3, offline environments required an exact name match for [project policies](../license_approval_policies.md).
+In GitLab 13.3 and later, GitLab matches the name of [project policies](../license_approval_policies.md)
with identifiers from the [SPDX license list](https://spdx.org/licenses/).
A local copy of the SPDX license list is distributed with the GitLab instance. If needed, the GitLab
instance's administrator can manually update it with a [Rake task](../../../raketasks/spdx.md).
diff --git a/doc/user/project/issue_board.md b/doc/user/project/issue_board.md
index ea775119beb..85779ae860f 100644
--- a/doc/user/project/issue_board.md
+++ b/doc/user/project/issue_board.md
@@ -150,7 +150,7 @@ Create lists to order issues by topic and quickly change them between topics or
such as between **UX**, **Frontend**, and **Backend**. The changes are reflected across boards,
as changing lists updates the labels on each issue accordingly.
-#### Advanced team handover
+#### Issue board workflow between teams
For example, suppose we have a UX team with an issue board that contains:
@@ -167,6 +167,9 @@ When finished with something, they move the card to **Frontend**. The Frontend t
Cards finished by the UX team automatically appear in the **Frontend** column when they are ready
for them.
+For a tutorial how to set up your boards in a similar way with [scoped labels](labels.md#scoped-labels), see
+[Tutorial: Set up issue boards for team hand-off](../../tutorials/boards_for_teams/index.md).
+
NOTE:
For a broader use case, see the blog post
[What is GitLab Flow?](https://about.gitlab.com/topics/version-control/what-is-gitlab-flow/).
diff --git a/doc/user/ssh.md b/doc/user/ssh.md
index a74c3fea360..b698f5a3edc 100644
--- a/doc/user/ssh.md
+++ b/doc/user/ssh.md
@@ -20,7 +20,7 @@ SSH uses two keys, a public key and a private key.
- The public key can be distributed.
- The private key should be protected.
-You cannot expose data by uploading your public key. When you need to copy or upload your SSH public key, make sure you do not accidentally copy or upload your private key instead.
+It is not possible to reveal confidential data by uploading your public key. When you need to copy or upload your SSH public key, make sure you do not accidentally copy or upload your private key instead.
You can use your private key to [sign commits](project/repository/ssh_signed_commits/index.md),
which makes your use of GitLab and your data even more secure.
diff --git a/lib/feature.rb b/lib/feature.rb
index eb2997a3551..94919edcce9 100644
--- a/lib/feature.rb
+++ b/lib/feature.rb
@@ -40,19 +40,6 @@ module Feature
class << self
delegate :group, to: :flipper
- def feature_flags_available?
- # When the DBMS is not available, an exception (e.g. PG::ConnectionBad) is raised
- active_db_connection = begin
- ActiveRecord::Base.connection.active? # rubocop:disable Database/MultipleDatabases
- rescue StandardError
- false
- end
-
- active_db_connection && Feature::FlipperFeature.table_exists?
- rescue ActiveRecord::NoDatabaseError
- false
- end
-
def all
flipper.features.to_a
end
diff --git a/lib/gitlab/import_export/json/streaming_serializer.rb b/lib/gitlab/import_export/json/streaming_serializer.rb
index 389ab8b4c97..9bb0770dc90 100644
--- a/lib/gitlab/import_export/json/streaming_serializer.rb
+++ b/lib/gitlab/import_export/json/streaming_serializer.rb
@@ -8,6 +8,8 @@ module Gitlab
BATCH_SIZE = 100
+ attr_reader :exported_objects_count
+
class Raw < String
def to_json(*_args)
to_s
@@ -21,6 +23,7 @@ module Gitlab
@relations_schema = relations_schema
@json_writer = json_writer
@logger = logger
+ @exported_objects_count = 0
end
def execute
@@ -40,21 +43,28 @@ module Gitlab
relations_schema.merge(include: nil, preloads: nil, unsafe: true))
json_writer.write_attributes(exportable_path, attributes)
+
+ increment_exported_objects_counter
end
- def serialize_relation(definition)
+ def serialize_relation(definition, options = {})
raise ArgumentError, 'definition needs to be Hash' unless definition.is_a?(Hash)
raise ArgumentError, 'definition needs to have exactly one Hash element' unless definition.one?
- key, options = definition.first
+ key, definition_options = definition.first
record = exportable.public_send(key) # rubocop: disable GitlabSecurity/PublicSend
+
+ if options[:batch_ids]
+ record = record.where(record.model.primary_key => Array.wrap(options[:batch_ids]).map(&:to_i))
+ end
+
if record.is_a?(ActiveRecord::Relation)
- serialize_many_relations(key, record, options)
+ serialize_many_relations(key, record, definition_options)
elsif record.respond_to?(:each) # this is to support `project_members` that return an Array
- serialize_many_each(key, record, options)
+ serialize_many_each(key, record, definition_options)
else
- serialize_single_relation(key, record, options)
+ serialize_single_relation(key, record, definition_options)
end
end
@@ -76,6 +86,8 @@ module Gitlab
items << exportable_json_record(record, options, key)
+ increment_exported_objects_counter
+
after_read_callback(record)
end
end
@@ -175,6 +187,8 @@ module Gitlab
enumerator = Enumerator.new do |items|
records.each do |record|
items << exportable_json_record(record, options, key)
+
+ increment_exported_objects_counter
end
end
@@ -187,6 +201,8 @@ module Gitlab
json = exportable_json_record(record, options, key)
json_writer.write_relation(@exportable_path, key, json)
+
+ increment_exported_objects_counter
end
def includes
@@ -263,6 +279,10 @@ module Gitlab
message += ". Number of records to export: #{size}" if size
logger.info(message: message, **log_base_data)
end
+
+ def increment_exported_objects_counter
+ @exported_objects_count += 1
+ end
end
end
end
diff --git a/lib/gitlab/json.rb b/lib/gitlab/json.rb
index bdfbe2041cd..e515d00f8d8 100644
--- a/lib/gitlab/json.rb
+++ b/lib/gitlab/json.rb
@@ -167,7 +167,7 @@ module Gitlab
# @return [Boolean, String, Array, Hash, Object]
# @raise [JSON::ParserError]
def handle_legacy_mode!(data)
- return data unless Feature.feature_flags_available?
+ return data unless Feature::FlipperFeature.table_exists?
return data unless Feature.enabled?(:json_wrapper_legacy_mode)
raise parser_error if INVALID_LEGACY_TYPES.any? { |type| data.is_a?(type) }
diff --git a/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job.rb b/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job.rb
index bb87104630c..2ca8cca363c 100644
--- a/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job.rb
+++ b/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job.rb
@@ -159,6 +159,8 @@ module Gitlab
end
def options
+ # Remove line below when FF `ci_pipeline_process_worker_dedup_until_executed` is removed
+ return job_deduplication[:options] if job_deduplication[:options]
return {} unless worker_klass
return {} unless worker_klass.respond_to?(:get_deduplication_options)
@@ -200,6 +202,8 @@ module Gitlab
end
def strategy
+ # Remove line below when FF `ci_pipeline_process_worker_dedup_until_executed` is removed
+ return job_deduplication[:strategy] if job_deduplication[:strategy]
return DEFAULT_STRATEGY unless worker_klass
return DEFAULT_STRATEGY unless worker_klass.respond_to?(:idempotent?)
return STRATEGY_NONE unless worker_klass.deduplication_enabled?
@@ -207,6 +211,22 @@ module Gitlab
worker_klass.get_deduplicate_strategy
end
+ # Returns the deduplicate settings stored in the job itself; remove this method
+ # when FF `ci_pipeline_process_worker_dedup_until_executed` is removed
+ def job_deduplication
+ return {} unless job['deduplicate']
+
+ # Sometimes this setting is returned with all string keys/values; we need
+ # to ensure the keys and values of the hash are fully symbolized or numeric
+ job['deduplicate'].deep_symbolize_keys.tap do |hash|
+ hash[:strategy] = hash[:strategy]&.to_sym
+ hash[:options]&.each do |k, v|
+ hash[:options][k] = k == :ttl ? v.to_i : v.to_sym
+ end
+ end.compact
+ end
+ strong_memoize_attr :job_deduplication
+
def worker_class_name
job['class']
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 447535a3229..900ef21b598 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -853,9 +853,6 @@ msgstr ""
msgid "%{level_name} is not allowed since the fork source project has lower visibility."
msgstr ""
-msgid "%{license_check_docs_link_start}License-Check%{link_end} is enabled for this project. This feature has been %{deprecation_docs_link_url}deprecated%{link_end} in GitLab 15.9 and is planned for %{removal_docs_link_url}removal%{link_end} in 16.0. You can create a %{scan_result_policy_link_start}scan result policy%{link_end} to continue enforcing your license approval requirements."
-msgstr ""
-
msgid "%{linkStart} Learn more%{linkEnd}."
msgstr ""
@@ -5552,9 +5549,6 @@ msgstr ""
msgid "Approved"
msgstr ""
-msgid "Approved MRs"
-msgstr ""
-
msgid "Approved members will use an additional seat in your subscription, which may override your user cap."
msgid_plural "Approved members will use an additional %d seats in your subscription, which may override your user cap."
msgstr[0] ""
@@ -5898,9 +5892,6 @@ msgstr ""
msgid "AsanaService|User Personal Access Token. User must have access to the task. All comments are attributed to this user."
msgstr ""
-msgid "Ascending"
-msgstr ""
-
msgid "Ask again later"
msgstr ""
@@ -9565,9 +9556,6 @@ msgstr ""
msgid "Closed (moved)"
msgstr ""
-msgid "Closed MRs"
-msgstr ""
-
msgid "Closed date"
msgstr ""
@@ -11030,6 +11018,9 @@ msgstr ""
msgid "ComplianceFramework|No pipeline configuration found"
msgstr ""
+msgid "ComplianceReport|Add framework"
+msgstr ""
+
msgid "ComplianceReport|Apply framework to selected projects"
msgstr ""
@@ -11060,9 +11051,6 @@ msgstr ""
msgid "ComplianceReport|Less than 2 approvers"
msgstr ""
-msgid "ComplianceReport|No framework"
-msgstr ""
-
msgid "ComplianceReport|No projects found"
msgstr ""
@@ -11783,18 +11771,9 @@ msgstr ""
msgid "ContributionAnalytics|%{createdCount} created, %{mergedCount} merged, %{closedCount} closed."
msgstr ""
-msgid "ContributionAnalytics|%{created} created, %{closed} closed."
-msgstr ""
-
-msgid "ContributionAnalytics|%{created} created, %{merged} merged, %{closed} closed."
-msgstr ""
-
msgid "ContributionAnalytics|%{pushCount} by %{authorCount}."
msgstr ""
-msgid "ContributionAnalytics|%{pushes}, more than %{commits} by %{contributors}."
-msgstr ""
-
msgid "ContributionAnalytics|Approved MRs"
msgstr ""
@@ -11870,9 +11849,6 @@ msgstr ""
msgid "Contributions for %{calendar_date}"
msgstr ""
-msgid "Contributions per group member"
-msgstr ""
-
msgid "Contributor"
msgstr ""
@@ -14874,9 +14850,6 @@ msgstr ""
msgid "Deprioritize label"
msgstr ""
-msgid "Descending"
-msgstr ""
-
msgid "Describe the goal of the changes and what reviewers should be aware of."
msgstr ""
@@ -25969,9 +25942,6 @@ msgstr ""
msgid "License overview"
msgstr ""
-msgid "License-Check has been %{deprecation_docs_link_url}deprecated%{link_end} in GitLab 15.9 and is planned for %{removal_docs_link_url}removal%{link_end} in 16.0. You can create a %{scan_result_policy_link_start}scan result policy%{link_end} to continue enforcing your license approval requirements."
-msgstr ""
-
msgid "LicenseCompliance|%{docLinkStart}License Approvals%{docLinkEnd} are active"
msgstr ""
@@ -26343,9 +26313,6 @@ msgstr ""
msgid "Loading %{name}"
msgstr ""
-msgid "Loading contribution stats for group members"
-msgstr ""
-
msgid "Loading files, directories, and submodules in the path %{path} for commit reference %{ref}"
msgstr ""
@@ -27649,9 +27616,6 @@ msgstr ""
msgid "Merged"
msgstr ""
-msgid "Merged MRs"
-msgstr ""
-
msgid "Merged branches are being deleted. This can take some time depending on the number of branches. Please refresh the page to see changes."
msgstr ""
@@ -28864,7 +28828,7 @@ msgstr ""
msgid "NavigationTheme|Red"
msgstr ""
-msgid "Navigation|Admin"
+msgid "Navigation|Admin Area"
msgstr ""
msgid "Navigation|Analyze"
@@ -30863,12 +30827,6 @@ msgstr ""
msgid "Opened"
msgstr ""
-msgid "Opened MRs"
-msgstr ""
-
-msgid "Opened issues"
-msgstr ""
-
msgid "OpenedNDaysAgo|Created"
msgstr ""
@@ -36267,9 +36225,6 @@ msgstr ""
msgid "PushRule|Reject unverified users"
msgstr ""
-msgid "Pushed"
-msgstr ""
-
msgid "Pushes"
msgstr ""
@@ -38278,6 +38233,9 @@ msgstr ""
msgid "Runners|If both settings are disabled, new runners cannot be registered."
msgstr ""
+msgid "Runners|In GitLab Runner 15.6, the use of registration tokens and runner parameters in the 'register' command was deprecated. They have been replaced by authentication tokens. %{linkStart}How does this impact my current registration workflow?%{linkEnd}"
+msgstr ""
+
msgid "Runners|Install GitLab Runner"
msgstr ""
@@ -39513,24 +39471,12 @@ msgstr ""
msgid "SecurityApprovals|A merge request approval is required when test coverage declines."
msgstr ""
-msgid "SecurityApprovals|A merge request approval is required when the license compliance report contains a denied license."
-msgstr ""
-
msgid "SecurityApprovals|Coverage-Check"
msgstr ""
msgid "SecurityApprovals|Learn more about Coverage-Check"
msgstr ""
-msgid "SecurityApprovals|Learn more about License-Check"
-msgstr ""
-
-msgid "SecurityApprovals|License-Check"
-msgstr ""
-
-msgid "SecurityApprovals|Requires approval for Denied licenses. %{linkStart}Learn more.%{linkEnd}"
-msgstr ""
-
msgid "SecurityApprovals|Requires approval for decreases in test coverage. %{linkStart}Learn more.%{linkEnd}"
msgstr ""
@@ -39864,9 +39810,6 @@ msgstr ""
msgid "SecurityOrchestration|License scanner finds any license %{matching} %{licenses}%{detection} in an open merge request targeting %{branches}."
msgstr ""
-msgid "SecurityOrchestration|License-Check has been deprecated"
-msgstr ""
-
msgid "SecurityOrchestration|New policy"
msgstr ""
@@ -42016,9 +41959,6 @@ msgstr ""
msgid "Something went wrong while fetching details"
msgstr ""
-msgid "Something went wrong while fetching group member contributions"
-msgstr ""
-
msgid "Something went wrong while fetching latest comments."
msgstr ""
@@ -46609,9 +46549,6 @@ msgstr ""
msgid "Total"
msgstr ""
-msgid "Total Contributions"
-msgstr ""
-
msgid "Total Score"
msgstr ""
diff --git a/package.json b/package.json
index 1bf9206def7..67389cf31a9 100644
--- a/package.json
+++ b/package.json
@@ -57,7 +57,7 @@
"@gitlab/favicon-overlay": "2.0.0",
"@gitlab/fonts": "^1.2.0",
"@gitlab/svgs": "3.40.0",
- "@gitlab/ui": "61.1.1",
+ "@gitlab/ui": "61.3.0",
"@gitlab/visual-review-tools": "1.7.3",
"@gitlab/web-ide": "0.0.1-dev-20230425040132",
"@mattiasbuelens/web-streams-adapter": "^0.1.0",
diff --git a/spec/factories/bulk_import/export_batches.rb b/spec/factories/bulk_import/export_batches.rb
index 4339b02d27e..f5f12696f5f 100644
--- a/spec/factories/bulk_import/export_batches.rb
+++ b/spec/factories/bulk_import/export_batches.rb
@@ -7,6 +7,7 @@ FactoryBot.define do
upload { association(:bulk_import_export_upload) }
status { 0 }
+ batch_number { 1 }
trait :started do
status { 0 }
diff --git a/spec/factories/bulk_import/exports.rb b/spec/factories/bulk_import/exports.rb
index dd8831ce33a..795a9bbfe20 100644
--- a/spec/factories/bulk_import/exports.rb
+++ b/spec/factories/bulk_import/exports.rb
@@ -20,5 +20,9 @@ FactoryBot.define do
trait :failed do
status { -1 }
end
+
+ trait :batched do
+ batched { true }
+ end
end
end
diff --git a/spec/factories/ci/processable.rb b/spec/factories/ci/processable.rb
index 49e66368f94..49756433713 100644
--- a/spec/factories/ci/processable.rb
+++ b/spec/factories/ci/processable.rb
@@ -26,13 +26,19 @@ FactoryBot.define do
before(:create) do |processable, evaluator|
next if processable.ci_stage
- if ci_stage = processable.pipeline.stages.find_by(name: evaluator.stage)
- processable.ci_stage = ci_stage
- else
- processable.ci_stage = create(:ci_stage, pipeline: processable.pipeline,
- project: processable.project || evaluator.project,
- name: evaluator.stage, position: evaluator.stage_idx, status: 'created')
- end
+ processable.ci_stage =
+ if ci_stage = processable.pipeline.stages.find_by(name: evaluator.stage)
+ ci_stage
+ else
+ create(
+ :ci_stage,
+ pipeline: processable.pipeline,
+ project: processable.project || evaluator.project,
+ name: evaluator.stage,
+ position: evaluator.stage_idx,
+ status: 'created'
+ )
+ end
end
trait :waiting_for_resource do
diff --git a/spec/factories/draft_note.rb b/spec/factories/draft_note.rb
index cde8831f169..8433271a3c5 100644
--- a/spec/factories/draft_note.rb
+++ b/spec/factories/draft_note.rb
@@ -28,9 +28,7 @@ FactoryBot.define do
end
position do
- association(:image_diff_position,
- file: path,
- diff_refs: diff_refs)
+ association(:image_diff_position, file: path, diff_refs: diff_refs)
end
end
end
diff --git a/spec/factories/environments.rb b/spec/factories/environments.rb
index 34843dab0fe..2df9f482bb9 100644
--- a/spec/factories/environments.rb
+++ b/spec/factories/environments.rb
@@ -46,20 +46,19 @@ FactoryBot.define do
after(:create) do |environment, evaluator|
pipeline = create(:ci_pipeline, project: environment.project)
- deployable = create(:ci_build, :success, name: "#{environment.name}:deploy",
- pipeline: pipeline)
-
- deployment = create(:deployment,
- :success,
- environment: environment,
- project: environment.project,
- deployable: deployable,
- ref: evaluator.ref,
- sha: environment.project.commit(evaluator.ref).id)
-
- teardown_build = create(:ci_build, :manual,
- name: "#{environment.name}:teardown",
- pipeline: pipeline)
+ deployable = create(:ci_build, :success, name: "#{environment.name}:deploy", pipeline: pipeline)
+
+ deployment = create(
+ :deployment,
+ :success,
+ environment: environment,
+ project: environment.project,
+ deployable: deployable,
+ ref: evaluator.ref,
+ sha: environment.project.commit(evaluator.ref).id
+ )
+
+ teardown_build = create(:ci_build, :manual, name: "#{environment.name}:teardown", pipeline: pipeline)
deployment.update_column(:on_stop, teardown_build.name)
environment.update_attribute(:deployments, [deployment])
diff --git a/spec/factories/group_members.rb b/spec/factories/group_members.rb
index c8ee52019a4..e1841745cb4 100644
--- a/spec/factories/group_members.rb
+++ b/spec/factories/group_members.rb
@@ -60,10 +60,12 @@ FactoryBot.define do
after(:build) do |group_member, evaluator|
if evaluator.tasks_to_be_done.present?
- build(:member_task,
- member: group_member,
- project: build(:project, namespace: group_member.source),
- tasks_to_be_done: evaluator.tasks_to_be_done)
+ build(
+ :member_task,
+ member: group_member,
+ project: build(:project, namespace: group_member.source),
+ tasks_to_be_done: evaluator.tasks_to_be_done
+ )
end
end
end
diff --git a/spec/factories/ml/candidates.rb b/spec/factories/ml/candidates.rb
index 9d049987cfd..b9a2320138a 100644
--- a/spec/factories/ml/candidates.rb
+++ b/spec/factories/ml/candidates.rb
@@ -21,10 +21,12 @@ FactoryBot.define do
trait :with_artifact do
after(:create) do |candidate|
- candidate.package = FactoryBot.create(:generic_package,
- name: candidate.package_name,
- version: candidate.package_version,
- project: candidate.project)
+ candidate.package = FactoryBot.create(
+ :generic_package,
+ name: candidate.package_name,
+ version: candidate.package_version,
+ project: candidate.project
+ )
end
end
end
diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb
index c58e7bb2e79..b1e7866f9ce 100644
--- a/spec/factories/notes.rb
+++ b/spec/factories/notes.rb
@@ -55,28 +55,34 @@ FactoryBot.define do
end
position do
- association(:text_diff_position,
- file: "files/ruby/popen.rb",
- old_line: nil,
- new_line: line_number,
- diff_refs: diff_refs)
+ association(
+ :text_diff_position,
+ file: "files/ruby/popen.rb",
+ old_line: nil,
+ new_line: line_number,
+ diff_refs: diff_refs
+ )
end
trait :folded_position do
position do
- association(:text_diff_position,
- file: "files/ruby/popen.rb",
- old_line: 1,
- new_line: 1,
- diff_refs: diff_refs)
+ association(
+ :text_diff_position,
+ file: "files/ruby/popen.rb",
+ old_line: 1,
+ new_line: 1,
+ diff_refs: diff_refs
+ )
end
end
factory :image_diff_note_on_merge_request do
position do
- association(:image_diff_position,
- file: "files/images/any_image.png",
- diff_refs: diff_refs)
+ association(
+ :image_diff_position,
+ file: "files/images/any_image.png",
+ diff_refs: diff_refs
+ )
end
end
end
@@ -101,9 +107,11 @@ FactoryBot.define do
factory :diff_note_on_design, parent: :note, traits: [:on_design], class: 'DiffNote' do
position do
- association(:image_diff_position,
- file: noteable.full_path,
- diff_refs: noteable.diff_refs)
+ association(
+ :image_diff_position,
+ file: noteable.full_path,
+ diff_refs: noteable.diff_refs
+ )
end
end
diff --git a/spec/features/dashboard/issuables_counter_spec.rb b/spec/features/dashboard/issuables_counter_spec.rb
index 5dc59cfa841..5e6ec007569 100644
--- a/spec/features/dashboard/issuables_counter_spec.rb
+++ b/spec/features/dashboard/issuables_counter_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe 'Navigation bar counter', :use_clean_rails_memory_store_caching,
sign_in(user)
end
- it 'reflects dashboard issues count' do
+ it 'reflects dashboard issues count', :js do
visit issues_path
expect_counters('issues', '1', n_("%d assigned issue", "%d assigned issues", 1) % 1)
diff --git a/spec/features/dashboard/issues_filter_spec.rb b/spec/features/dashboard/issues_filter_spec.rb
index ee1e704c6c4..e67e04ee0b0 100644
--- a/spec/features/dashboard/issues_filter_spec.rb
+++ b/spec/features/dashboard/issues_filter_spec.rb
@@ -6,43 +6,55 @@ RSpec.describe 'Dashboard Issues filtering', :js, feature_category: :team_planni
include Features::SortingHelpers
include FilteredSearchHelpers
- let(:user) { create(:user) }
- let(:project) { create(:project) }
- let(:milestone) { create(:milestone, project: project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:milestone) { create(:milestone, project: project) }
- let!(:issue) { create(:issue, project: project, author: user, assignees: [user]) }
- let!(:issue2) { create(:issue, project: project, author: user, assignees: [user], milestone: milestone) }
+ let_it_be(:issue) { create(:issue, project: project, author: user, assignees: [user]) }
+ let_it_be(:issue2) { create(:issue, project: project, author: user, assignees: [user], milestone: milestone) }
+ let_it_be(:label) { create(:label, project: project, title: 'bug') }
+ let_it_be(:label_link) { create(:label_link, label: label, target: issue) }
+
+ let_it_be(:project2) { create(:project, namespace: user.namespace) }
+ let_it_be(:label2) { create(:label, title: 'bug') }
before do
+ project.labels << label
+ project2.labels << label2
project.add_maintainer(user)
sign_in(user)
-
- visit_issues
end
context 'without any filter' do
it 'shows error message' do
+ visit issues_dashboard_path
+
expect(page).to have_content 'Please select at least one filter to see results'
end
end
context 'filtering by milestone' do
it 'shows all issues with no milestone' do
- input_filtered_search("milestone:=none")
+ visit issues_dashboard_path
+
+ select_tokens 'Milestone', '=', 'None', submit: true
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_selector('.issue', count: 1)
end
it 'shows all issues with the selected milestone' do
- input_filtered_search("milestone:=%\"#{milestone.title}\"")
+ visit issues_dashboard_path
+
+ select_tokens 'Milestone', '=', milestone.title, submit: true
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_selector('.issue', count: 1)
end
it 'updates atom feed link' do
- visit_issues(milestone_title: '', assignee_username: user.username)
+ visit issues_dashboard_path(milestone_title: '', assignee_username: user.username)
+ click_button 'Actions'
link = find_link('Subscribe to RSS feed')
params = CGI.parse(URI.parse(link[:href]).query)
@@ -59,40 +71,47 @@ RSpec.describe 'Dashboard Issues filtering', :js, feature_category: :team_planni
end
context 'filtering by label' do
- let(:label) { create(:label, project: project) }
- let!(:label_link) { create(:label_link, label: label, target: issue) }
+ before do
+ visit issues_dashboard_path
+ end
it 'shows all issues with the selected label' do
- input_filtered_search("label:=~#{label.title}")
+ select_tokens 'Label', '=', label.title, submit: true
- page.within 'ul.content-list' do
- expect(page).to have_content issue.title
- expect(page).not_to have_content issue2.title
- end
+ expect(page).to have_content issue.title
+ expect(page).not_to have_content issue2.title
+ end
+
+ it 'removes duplicate labels' do
+ select_tokens 'Label', '='
+ send_keys 'bu'
+
+ expect_suggestion('bug')
+ expect_suggestion_count(3) # Expect None, Any, and bug
end
end
context 'sorting' do
before do
- visit_issues(assignee_username: user.username)
+ visit issues_dashboard_path(assignee_username: user.username)
end
it 'remembers last sorting value' do
- pajamas_sort_by(s_('SortOptions|Created date'))
- visit_issues(assignee_username: user.username)
+ click_button 'Created date'
+ click_button 'Updated date'
+
+ visit issues_dashboard_path(assignee_username: user.username)
- expect(page).to have_button('Created date')
+ expect(page).to have_button('Updated date')
end
it 'keeps sorting issues after visiting Projects Issues page' do
- pajamas_sort_by(s_('SortOptions|Created date'))
+ click_button 'Created date'
+ click_button 'Due date'
+
visit project_issues_path(project)
- expect(page).to have_button('Created date')
+ expect(page).to have_button('Due date')
end
end
-
- def visit_issues(...)
- visit issues_dashboard_path(...)
- end
end
diff --git a/spec/features/dashboard/issues_spec.rb b/spec/features/dashboard/issues_spec.rb
index 4499aa021ff..70d9f7e5137 100644
--- a/spec/features/dashboard/issues_spec.rb
+++ b/spec/features/dashboard/issues_spec.rb
@@ -5,15 +5,15 @@ require 'spec_helper'
RSpec.describe 'Dashboard Issues', feature_category: :team_planning do
include FilteredSearchHelpers
- let(:current_user) { create :user }
- let(:user) { current_user } # Shared examples depend on this being available
- let!(:public_project) { create(:project, :public) }
- let(:project) { create(:project) }
- let(:project_with_issues_disabled) { create(:project, :issues_disabled) }
- let!(:authored_issue) { create :issue, author: current_user, project: project }
- let!(:authored_issue_on_public_project) { create :issue, author: current_user, project: public_project }
- let!(:assigned_issue) { create :issue, assignees: [current_user], project: project }
- let!(:other_issue) { create :issue, project: project }
+ let_it_be(:current_user) { create :user }
+ let_it_be(:user) { current_user } # Shared examples depend on this being available
+ let_it_be(:public_project) { create(:project, :public) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:project_with_issues_disabled) { create(:project, :issues_disabled) }
+ let_it_be(:authored_issue) { create :issue, author: current_user, project: project }
+ let_it_be(:authored_issue_on_public_project) { create :issue, author: current_user, project: public_project }
+ let_it_be(:assigned_issue) { create :issue, assignees: [current_user], project: project }
+ let_it_be(:other_issue) { create :issue, project: project }
before do
[project, project_with_issues_disabled].each { |project| project.add_maintainer(current_user) }
@@ -23,16 +23,16 @@ RSpec.describe 'Dashboard Issues', feature_category: :team_planning do
it_behaves_like 'a "Your work" page with sidebar and breadcrumbs', :issues_dashboard_path, :issues
- describe 'issues' do
+ describe 'issues', :js do
it 'shows issues assigned to current user' do
expect(page).to have_content(assigned_issue.title)
expect(page).not_to have_content(authored_issue.title)
expect(page).not_to have_content(other_issue.title)
end
- it 'shows issues when current user is author', :js do
- reset_filters
- input_filtered_search("author:=#{current_user.to_reference}")
+ it 'shows issues when current user is author' do
+ click_button 'Clear'
+ select_tokens 'Author', '=', current_user.to_reference, submit: true
expect(page).to have_content(authored_issue.title)
expect(page).to have_content(authored_issue_on_public_project.title)
@@ -41,12 +41,21 @@ RSpec.describe 'Dashboard Issues', feature_category: :team_planning do
end
it 'state filter tabs work' do
- find('#state-closed').click
- expect(page).to have_current_path(issues_dashboard_url(assignee_username: current_user.username, state: 'closed'), url: true)
+ click_link 'Closed'
+
+ expect(page).not_to have_content(assigned_issue.title)
+ expect(page).not_to have_content(authored_issue.title)
+ expect(page).not_to have_content(other_issue.title)
end
- it_behaves_like "it has an RSS button with current_user's feed token"
- it_behaves_like "an autodiscoverable RSS feed with current_user's feed token"
+ describe 'RSS link' do
+ before do
+ click_button 'Actions'
+ end
+
+ it_behaves_like "it has an RSS link with current_user's feed token"
+ it_behaves_like "an autodiscoverable RSS feed with current_user's feed token"
+ end
end
describe 'new issue dropdown' do
diff --git a/spec/features/dashboard/label_filter_spec.rb b/spec/features/dashboard/label_filter_spec.rb
deleted file mode 100644
index f116c84ff40..00000000000
--- a/spec/features/dashboard/label_filter_spec.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Dashboard > label filter', :js, feature_category: :team_planning do
- include FilteredSearchHelpers
-
- let(:filtered_search) { find('.filtered-search') }
- let(:filter_dropdown) { find("#js-dropdown-label .filter-dropdown") }
-
- let(:user) { create(:user) }
- let(:project) { create(:project, name: 'test', namespace: user.namespace) }
- let(:project2) { create(:project, name: 'test2', path: 'test2', namespace: user.namespace) }
- let(:label) { create(:label, title: 'bug', color: '#ff0000') }
- let(:label2) { create(:label, title: 'bug') }
-
- before do
- project.labels << label
- project2.labels << label2
-
- sign_in(user)
- visit issues_dashboard_path
-
- init_label_search
- end
-
- context 'duplicate labels' do
- it 'removes duplicate labels' do
- filtered_search.send_keys('bu')
-
- expect(filter_dropdown).to have_selector('.filter-dropdown-item', text: 'bug', count: 1)
- end
- end
-end
diff --git a/spec/features/groups/labels/index_spec.rb b/spec/features/groups/labels/index_spec.rb
index ea27fa2c5d9..7b0a38a83db 100644
--- a/spec/features/groups/labels/index_spec.rb
+++ b/spec/features/groups/labels/index_spec.rb
@@ -24,6 +24,7 @@ RSpec.describe 'Group labels', feature_category: :team_planning do
end
it 'shows an edit label button', :js do
- expect(page).to have_selector('.edit')
+ click_button 'Label actions dropdown'
+ expect(page).to have_link('Edit')
end
end
diff --git a/spec/features/merge_requests/filters_generic_behavior_spec.rb b/spec/features/merge_requests/filters_generic_behavior_spec.rb
index 197b9fa770d..4dbbde5168b 100644
--- a/spec/features/merge_requests/filters_generic_behavior_spec.rb
+++ b/spec/features/merge_requests/filters_generic_behavior_spec.rb
@@ -71,7 +71,7 @@ RSpec.describe 'Merge Requests > Filters generic behavior', :js, feature_categor
context 'filter dropdown' do
it 'filters by label name' do
- init_label_search
+ filtered_search.set('label:=')
filtered_search.send_keys('~bug')
page.within '.filter-dropdown' do
diff --git a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap
index e379aba094c..ddeab3e3b62 100644
--- a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap
+++ b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap
@@ -35,6 +35,7 @@ exports[`AddContextCommitsModal renders modal with 2 tabs 1`] = `
placeholder="Search or filter commits"
searchbuttonattributes="[object Object]"
searchinputattributes="[object Object]"
+ searchtextoptionlabel="Search for this text"
value=""
/>
diff --git a/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap b/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap
index c979ee5a1d2..788e80de3f6 100644
--- a/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap
+++ b/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap
@@ -21,7 +21,7 @@ exports[`Comment templates list item component renders list item 1`] = `
class="gl-new-dropdown gl-disclosure-dropdown"
>
<button
- aria-controls="base-dropdown-5"
+ aria-controls="base-dropdown-7"
aria-labelledby="actions-toggle-3"
class="btn btn-default btn-md gl-button btn-default-tertiary gl-new-dropdown-toggle gl-new-dropdown-icon-only gl-new-dropdown-toggle-no-caret"
data-testid="base-dropdown-toggle"
@@ -60,7 +60,7 @@ exports[`Comment templates list item component renders list item 1`] = `
<div
class="gl-new-dropdown-panel"
data-testid="base-dropdown-menu"
- id="base-dropdown-5"
+ id="base-dropdown-7"
>
<div
class="gl-new-dropdown-inner"
diff --git a/spec/frontend/issuable/components/related_issuable_item_spec.js b/spec/frontend/issuable/components/related_issuable_item_spec.js
index 3e23558ceb4..68b41de4730 100644
--- a/spec/frontend/issuable/components/related_issuable_item_spec.js
+++ b/spec/frontend/issuable/components/related_issuable_item_spec.js
@@ -1,14 +1,18 @@
import { GlIcon, GlLink, GlButton } from '@gitlab/ui';
+import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
import { formatDate } from '~/lib/utils/datetime_utility';
import { updateHistory } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
+import { stubComponent } from 'helpers/stub_component';
import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue';
import IssueMilestone from '~/issuable/components/issue_milestone.vue';
import IssueAssignees from '~/issuable/components/issue_assignees.vue';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
+import { mockWorkItemCommentNote } from 'jest/work_items/mock_data';
import { defaultAssignees, defaultMilestone } from './related_issuable_mock_data';
jest.mock('~/lib/utils/url_utility', () => ({
@@ -18,6 +22,7 @@ jest.mock('~/lib/utils/url_utility', () => ({
describe('RelatedIssuableItem', () => {
let wrapper;
+ let showModalSpy;
const defaultProps = {
idKey: 1,
@@ -40,13 +45,25 @@ describe('RelatedIssuableItem', () => {
const findRemoveButton = () => wrapper.findComponent(GlButton);
const findTitleLink = () => wrapper.findComponent(GlLink);
const findWorkItemDetailModal = () => wrapper.findComponent(WorkItemDetailModal);
+ const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector);
function mountComponent({ data = {}, props = {} } = {}) {
+ showModalSpy = jest.fn();
wrapper = shallowMount(RelatedIssuableItem, {
propsData: {
...defaultProps,
...props,
},
+ provide: {
+ reportAbusePath: '/report/abuse/path',
+ },
+ stubs: {
+ WorkItemDetailModal: stubComponent(WorkItemDetailModal, {
+ methods: {
+ show: showModalSpy,
+ },
+ }),
+ },
data() {
return data;
},
@@ -265,4 +282,30 @@ describe('RelatedIssuableItem', () => {
});
});
});
+
+ describe('abuse category selector', () => {
+ beforeEach(() => {
+ mountComponent({ props: { workItemType: 'TASK' } });
+ findTitleLink().vm.$emit('click', { preventDefault: () => {} });
+ });
+
+ it('should not be visible by default', () => {
+ expect(showModalSpy).toHaveBeenCalled();
+ expect(findAbuseCategorySelector().exists()).toBe(false);
+ });
+
+ it('should be visible when the work item modal emits `openReportAbuse` event', async () => {
+ findWorkItemDetailModal().vm.$emit('openReportAbuse', mockWorkItemCommentNote);
+
+ await nextTick();
+
+ expect(findAbuseCategorySelector().exists()).toBe(true);
+
+ findAbuseCategorySelector().vm.$emit('close-drawer');
+
+ await nextTick();
+
+ expect(findAbuseCategorySelector().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
index b9580b90c12..8807bc311f0 100644
--- a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
+++ b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
@@ -22,6 +22,41 @@ describe('RelatedIssuesBlock', () => {
const findRelatedIssuesBody = () => wrapper.findByTestId('related-issues-body');
const findIssueCountBadgeAddButton = () => wrapper.findByTestId('related-issues-plus-button');
+ const createComponent = ({
+ mountFn = mountExtended,
+ pathIdSeparator = PathIdSeparator.Issue,
+ issuableType = TYPE_ISSUE,
+ canAdmin = false,
+ helpPath = '',
+ isFetching = false,
+ isFormVisible = false,
+ relatedIssues = [],
+ showCategorizedIssues = false,
+ autoCompleteEpics = true,
+ slots = '',
+ } = {}) => {
+ wrapper = mountFn(RelatedIssuesBlock, {
+ propsData: {
+ pathIdSeparator,
+ issuableType,
+ canAdmin,
+ helpPath,
+ isFetching,
+ isFormVisible,
+ relatedIssues,
+ showCategorizedIssues,
+ autoCompleteEpics,
+ },
+ provide: {
+ reportAbusePath: '/report/abuse/path',
+ },
+ stubs: {
+ GlCard,
+ },
+ slots,
+ });
+ };
+
afterEach(() => {
if (wrapper) {
wrapper.destroy();
@@ -31,12 +66,7 @@ describe('RelatedIssuesBlock', () => {
describe('with defaults', () => {
beforeEach(() => {
- wrapper = mountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: TYPE_ISSUE,
- },
- });
+ createComponent();
});
it.each`
@@ -46,13 +76,11 @@ describe('RelatedIssuesBlock', () => {
`(
'displays "$titleText" in the header and "$addButtonText" aria-label for add button when issuableType is set to "$issuableType"',
({ issuableType, pathIdSeparator, titleText, addButtonText }) => {
- wrapper = mountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator,
- issuableType,
- canAdmin: true,
- helpPath: '/help/user/project/issues/related_issues',
- },
+ createComponent({
+ pathIdSeparator,
+ issuableType,
+ canAdmin: true,
+ helpPath: '/help/user/project/issues/related_issues',
});
expect(wrapper.find('.card-title').text()).toContain(titleText);
@@ -73,14 +101,8 @@ describe('RelatedIssuesBlock', () => {
it('displays header text slot data', () => {
const headerText = '<div>custom header text</div>';
- wrapper = shallowMountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: 'issue',
- },
- stubs: {
- GlCard,
- },
+ createComponent({
+ mountFn: shallowMountExtended,
slots: { 'header-text': headerText },
});
@@ -92,14 +114,8 @@ describe('RelatedIssuesBlock', () => {
it('displays header actions slot data', () => {
const headerActions = '<button data-testid="custom-button">custom button</button>';
- wrapper = shallowMountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: 'issue',
- },
- stubs: {
- GlCard,
- },
+ createComponent({
+ mountFn: shallowMountExtended,
slots: { 'header-actions': headerActions },
});
@@ -109,12 +125,8 @@ describe('RelatedIssuesBlock', () => {
describe('with isFetching=true', () => {
beforeEach(() => {
- wrapper = mountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- isFetching: true,
- issuableType: 'issue',
- },
+ createComponent({
+ isFetching: true,
});
});
@@ -125,13 +137,7 @@ describe('RelatedIssuesBlock', () => {
describe('with canAddRelatedIssues=true', () => {
beforeEach(() => {
- wrapper = mountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- canAdmin: true,
- issuableType: 'issue',
- },
- });
+ createComponent({ canAdmin: true });
});
it('can add new related issues', () => {
@@ -141,14 +147,7 @@ describe('RelatedIssuesBlock', () => {
describe('with isFormVisible=true', () => {
beforeEach(() => {
- wrapper = mountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- isFormVisible: true,
- issuableType: 'issue',
- autoCompleteEpics: false,
- },
- });
+ createComponent({ isFormVisible: true, autoCompleteEpics: false });
});
it('shows add related issues form', () => {
@@ -164,19 +163,14 @@ describe('RelatedIssuesBlock', () => {
const issueList = () => wrapper.findAll('.js-related-issues-token-list-item');
const categorizedHeadings = () => wrapper.findAll('h4');
const headingTextAt = (index) => categorizedHeadings().at(index).text();
- const mountComponent = (showCategorizedIssues) => {
- wrapper = mountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- relatedIssues: [issuable1, issuable2, issuable3],
- issuableType: 'issue',
- showCategorizedIssues,
- },
- });
- };
describe('when showCategorizedIssues=true', () => {
- beforeEach(() => mountComponent(true));
+ beforeEach(() =>
+ createComponent({
+ showCategorizedIssues: true,
+ relatedIssues: [issuable1, issuable2, issuable3],
+ }),
+ );
it('should render issue tokens items', () => {
expect(issueList()).toHaveLength(3);
@@ -203,8 +197,10 @@ describe('RelatedIssuesBlock', () => {
describe('when showCategorizedIssues=false', () => {
it('should render issues as a flat list with no header', () => {
- mountComponent(false);
-
+ createComponent({
+ showCategorizedIssues: false,
+ relatedIssues: [issuable1, issuable2, issuable3],
+ });
expect(issueList()).toHaveLength(3);
expect(categorizedHeadings()).toHaveLength(0);
});
@@ -223,14 +219,8 @@ describe('RelatedIssuesBlock', () => {
},
].forEach(({ issuableType, icon }) => {
it(`issuableType=${issuableType} is passed`, () => {
- wrapper = shallowMountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType,
- },
- stubs: {
- GlCard,
- },
+ createComponent({
+ issuableType,
});
const iconComponent = wrapper.findComponent(GlIcon);
@@ -242,15 +232,8 @@ describe('RelatedIssuesBlock', () => {
describe('toggle', () => {
beforeEach(() => {
- wrapper = shallowMountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- relatedIssues: [issuable1, issuable2, issuable3],
- issuableType: TYPE_ISSUE,
- },
- stubs: {
- GlCard,
- },
+ createComponent({
+ relatedIssues: [issuable1, issuable2, issuable3],
});
});
@@ -280,14 +263,12 @@ describe('RelatedIssuesBlock', () => {
`(
'displays "$emptyText" in the body and "$helpLinkText" aria-label for help link',
({ issuableType, pathIdSeparator, showCategorizedIssues, emptyText, helpLinkText }) => {
- wrapper = mountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator,
- issuableType,
- canAdmin: true,
- helpPath: '/help/user/project/issues/related_issues',
- showCategorizedIssues,
- },
+ createComponent({
+ pathIdSeparator,
+ issuableType,
+ canAdmin: true,
+ helpPath: '/help/user/project/issues/related_issues',
+ showCategorizedIssues,
});
expect(wrapper.findByTestId('related-issues-body').text()).toContain(emptyText);
diff --git a/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js
index 9bb71ec3dcb..0a6a0a90d44 100644
--- a/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js
+++ b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js
@@ -13,6 +13,30 @@ import { PathIdSeparator } from '~/related_issues/constants';
describe('RelatedIssuesList', () => {
let wrapper;
+ const createComponent = ({
+ mountFn = shallowMount,
+ pathIdSeparator = PathIdSeparator.Issue,
+ issuableType = 'issue',
+ listLinkType = 'relates_to',
+ heading = '',
+ isFetching = false,
+ relatedIssues = [],
+ } = {}) => {
+ wrapper = mountFn(RelatedIssuesList, {
+ propsData: {
+ pathIdSeparator,
+ issuableType,
+ listLinkType,
+ heading,
+ isFetching,
+ relatedIssues,
+ },
+ provide: {
+ reportAbusePath: '/report/abuse/path',
+ },
+ });
+ };
+
afterEach(() => {
if (wrapper) {
wrapper.destroy();
@@ -24,14 +48,7 @@ describe('RelatedIssuesList', () => {
const heading = 'Related to';
beforeEach(() => {
- wrapper = shallowMount(RelatedIssuesList, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: 'issue',
- listLinkType: 'relates_to',
- heading,
- },
- });
+ createComponent({ heading });
});
it('assigns value of listLinkType prop to data attribute', () => {
@@ -49,13 +66,7 @@ describe('RelatedIssuesList', () => {
describe('with isFetching=true', () => {
beforeEach(() => {
- wrapper = shallowMount(RelatedIssuesList, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- isFetching: true,
- issuableType: 'issue',
- },
- });
+ createComponent({ isFetching: true });
});
it('should show loading icon', () => {
@@ -65,13 +76,7 @@ describe('RelatedIssuesList', () => {
describe('methods', () => {
beforeEach(() => {
- wrapper = shallowMount(RelatedIssuesList, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- relatedIssues: [issuable1, issuable2, issuable3, issuable4, issuable5],
- issuableType: 'issue',
- },
- });
+ createComponent({ relatedIssues: [issuable1, issuable2, issuable3, issuable4, issuable5] });
});
it('updates the order correctly when an item is moved to the top', () => {
@@ -112,23 +117,17 @@ describe('RelatedIssuesList', () => {
});
describe('issuableOrderingId returns correct issuable order id when', () => {
- it('issuableType is epic', () => {
- wrapper = shallowMount(RelatedIssuesList, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: 'issue',
- },
+ it('issuableType is issue', () => {
+ createComponent({
+ issuableType: 'issue',
});
expect(wrapper.vm.issuableOrderingId(issuable1)).toBe(issuable1.epicIssueId);
});
- it('issuableType is issue', () => {
- wrapper = shallowMount(RelatedIssuesList, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: 'epic',
- },
+ it('issuableType is epic', () => {
+ createComponent({
+ issuableType: 'epic',
});
expect(wrapper.vm.issuableOrderingId(issuable1)).toBe(issuable1.id);
@@ -143,12 +142,9 @@ describe('RelatedIssuesList', () => {
});
it('issuableType is epic', () => {
- wrapper = shallowMount(RelatedIssuesList, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: 'epic',
- relatedIssues,
- },
+ createComponent({
+ issuableType: 'epic',
+ relatedIssues,
});
const listItems = wrapper.vm.$el.querySelectorAll('.list-item');
@@ -159,12 +155,9 @@ describe('RelatedIssuesList', () => {
});
it('issuableType is issue', () => {
- wrapper = shallowMount(RelatedIssuesList, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: 'issue',
- relatedIssues,
- },
+ createComponent({
+ issuableType: 'issue',
+ relatedIssues,
});
const listItems = wrapper.vm.$el.querySelectorAll('.list-item');
@@ -177,13 +170,7 @@ describe('RelatedIssuesList', () => {
describe('related item contents', () => {
beforeAll(() => {
- wrapper = mount(RelatedIssuesList, {
- propsData: {
- issuableType: 'issue',
- pathIdSeparator: PathIdSeparator.Issue,
- relatedIssues: [issuable1],
- },
- });
+ createComponent({ mountFn: mount, relatedIssues: [issuable1] });
});
it('shows due date', () => {
diff --git a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js
index 1383013aedb..b119c836411 100644
--- a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js
+++ b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js
@@ -42,6 +42,9 @@ describe('RelatedIssuesRoot', () => {
...defaultProps,
...props,
},
+ provide: {
+ reportAbusePath: '/report/abuse/path',
+ },
data() {
return data;
},
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
index f115ec2d6ca..d87aa3194d2 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
@@ -99,6 +99,7 @@ function createComponent({
portalName: 'fake target',
alignSuggestions: jest.fn(),
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
filteredSearchSuggestionListInstance: {
register: jest.fn(),
unregister: jest.fn(),
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js
index a6bb32736db..6bbbfd838a0 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js
@@ -46,6 +46,7 @@ function createComponent(options = {}) {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
stubs,
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js
index ce134f7d24e..fb8cea09a9b 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js
@@ -71,6 +71,7 @@ describe('CrmContactToken', () => {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
stubs,
listeners,
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js
index f41c5b5d432..20369342220 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js
@@ -70,6 +70,7 @@ describe('CrmOrganizationToken', () => {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
stubs,
listeners,
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js
index 0dddae50c4e..5e675c10038 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js
@@ -52,6 +52,7 @@ function createComponent(options = {}) {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
stubs,
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
index 696483df8ef..c55721fe032 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
@@ -51,6 +51,7 @@ function createComponent(options = {}) {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
stubs,
listeners,
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
index c758e550ba2..db51b4a05b1 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
@@ -49,6 +49,7 @@ function createComponent(options = {}) {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
stubs,
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js
index 5190ab919b1..79fd527cbe3 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js
@@ -24,6 +24,7 @@ describe('ReleaseToken', () => {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js
index d0a6519f16d..e4ca7dcb19a 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js
@@ -57,6 +57,7 @@ function createComponent(options = {}) {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
data() {
return { ...data };
diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js
index cd4ebe334c0..015e08ed760 100644
--- a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js
+++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js
@@ -1,6 +1,6 @@
import { GlAlert, GlModal, GlButton, GlSkeletonLoader } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import { shallowMount } from '@vue/test-utils';
+import { shallowMount, ErrorWrapper } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -41,7 +41,12 @@ describe('RunnerInstructionsModal component', () => {
let runnerPlatformsHandler;
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
- const findAlert = () => wrapper.findComponent(GlAlert);
+ const findAlert = (variant = 'danger') => {
+ const { wrappers } = wrapper
+ .findAllComponents(GlAlert)
+ .filter((w) => w.props('variant') === variant);
+ return wrappers[0] || new ErrorWrapper();
+ };
const findModal = () => wrapper.findComponent(GlModal);
const findPlatformButtonGroup = () => wrapper.findByTestId('platform-buttons');
const findPlatformButtons = () => findPlatformButtonGroup().findAllComponents(GlButton);
@@ -84,6 +89,10 @@ describe('RunnerInstructionsModal component', () => {
expect(findAlert().exists()).toBe(false);
});
+ it('should not show deprecation alert', () => {
+ expect(findAlert('warning').exists()).toBe(false);
+ });
+
it('should contain a number of platforms buttons', () => {
expect(runnerPlatformsHandler).toHaveBeenCalledWith({});
@@ -100,6 +109,21 @@ describe('RunnerInstructionsModal component', () => {
);
});
+ describe.each`
+ glFeatures | deprecationAlertExists
+ ${{}} | ${false}
+ ${{ createRunnerWorkflowForAdmin: true }} | ${true}
+ ${{ createRunnerWorkflowForNamespace: true }} | ${true}
+ `('with features $glFeatures', ({ glFeatures, deprecationAlertExists }) => {
+ beforeEach(() => {
+ createComponent({ provide: { glFeatures } });
+ });
+
+ it(`alert is ${deprecationAlertExists ? 'shown' : 'not shown'}`, () => {
+ expect(findAlert('warning').exists()).toBe(deprecationAlertExists);
+ });
+ });
+
describe('when the modal resizes', () => {
it('to an xs viewport', async () => {
MockResizeObserver.mockResize('xs');
diff --git a/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js b/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js
index b406c9d843a..99bf391e261 100644
--- a/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js
+++ b/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js
@@ -22,6 +22,7 @@ describe('Work Item Note Actions', () => {
const findDeleteNoteButton = () => wrapper.find('[data-testid="delete-note-action"]');
const findCopyLinkButton = () => wrapper.find('[data-testid="copy-link-action"]');
const findAssignUnassignButton = () => wrapper.find('[data-testid="assign-note-action"]');
+ const findReportAbuseToAdminButton = () => wrapper.find('[data-testid="abuse-note-action"]');
const addEmojiMutationResolver = jest.fn().mockResolvedValue({
data: {
@@ -39,6 +40,7 @@ describe('Work Item Note Actions', () => {
showEdit = true,
showAwardEmoji = true,
showAssignUnassign = false,
+ canReportAbuse = false,
} = {}) => {
wrapper = shallowMount(WorkItemNoteActions, {
propsData: {
@@ -47,6 +49,7 @@ describe('Work Item Note Actions', () => {
noteId,
showAwardEmoji,
showAssignUnassign,
+ canReportAbuse,
},
provide: {
glFeatures: {
@@ -195,4 +198,30 @@ describe('Work Item Note Actions', () => {
expect(wrapper.emitted('assignUser')).toEqual([[]]);
});
});
+
+ describe('report abuse to admin', () => {
+ it('should not report abuse to admin by default', () => {
+ createComponent();
+
+ expect(findReportAbuseToAdminButton().exists()).toBe(false);
+ });
+
+ it('should display assign/unassign when the props is true', () => {
+ createComponent({
+ canReportAbuse: true,
+ });
+
+ expect(findReportAbuseToAdminButton().exists()).toBe(true);
+ });
+
+ it('should emit `reportAbuse` event when report abuse action is clicked', () => {
+ createComponent({
+ canReportAbuse: true,
+ });
+
+ findReportAbuseToAdminButton().vm.$emit('click');
+
+ expect(wrapper.emitted('reportAbuse')).toEqual([[]]);
+ });
+ });
});
diff --git a/spec/frontend/work_items/components/notes/work_item_note_spec.js b/spec/frontend/work_items/components/notes/work_item_note_spec.js
index 69b7c7b0828..f8be2f5667b 100644
--- a/spec/frontend/work_items/components/notes/work_item_note_spec.js
+++ b/spec/frontend/work_items/components/notes/work_item_note_spec.js
@@ -300,5 +300,23 @@ describe('Work Item Note', () => {
});
});
});
+
+ describe('report abuse props', () => {
+ it.each`
+ currentUserId | canReportAbuse | sameAsAuthor
+ ${1} | ${false} | ${'same as'}
+ ${4} | ${true} | ${'not same as'}
+ `(
+ 'should be $canReportAbuse when the author is $sameAsAuthor as the author of the note',
+ ({ currentUserId, canReportAbuse }) => {
+ window.gon = {
+ current_user_id: currentUserId,
+ };
+ createComponent();
+
+ expect(findNoteActions().props('canReportAbuse')).toBe(canReportAbuse);
+ },
+ );
+ });
});
});
diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js
index 46189850e09..1d164648e27 100644
--- a/spec/frontend/work_items/components/work_item_detail_spec.js
+++ b/spec/frontend/work_items/components/work_item_detail_spec.js
@@ -26,6 +26,7 @@ import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue';
import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue';
import WorkItemNotes from '~/work_items/components/work_item_notes.vue';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import { i18n } from '~/work_items/constants';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import workItemDatesSubscription from '~/graphql_shared/subscriptions/work_item_dates.subscription.graphql';
@@ -43,6 +44,7 @@ import {
workItemAssigneesSubscriptionResponse,
workItemMilestoneSubscriptionResponse,
objectiveType,
+ mockWorkItemCommentNote,
} from '../mock_data';
describe('WorkItemDetail component', () => {
@@ -88,6 +90,7 @@ describe('WorkItemDetail component', () => {
const findHierarchyTree = () => wrapper.findComponent(WorkItemTree);
const findNotesWidget = () => wrapper.findComponent(WorkItemNotes);
const findModal = () => wrapper.findComponent(WorkItemDetailModal);
+ const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector);
const createComponent = ({
isModal = false,
@@ -128,6 +131,7 @@ describe('WorkItemDetail component', () => {
hasIssuableHealthStatusFeature: true,
projectNamespace: 'namespace',
fullPath: 'group/project',
+ reportAbusePath: '/report/abuse/path',
},
stubs: {
WorkItemWeight: true,
@@ -725,4 +729,30 @@ describe('WorkItemDetail component', () => {
expect(findCreatedUpdated().exists()).toBe(true);
});
+
+ describe('abuse category selector', () => {
+ beforeEach(async () => {
+ setWindowLocation('?work_item_id=2');
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('should not be visible by default', () => {
+ expect(findAbuseCategorySelector().exists()).toBe(false);
+ });
+
+ it('should be visible when the work item modal emits `openReportAbuse` event', async () => {
+ findModal().vm.$emit('openReportAbuse', mockWorkItemCommentNote);
+
+ await nextTick();
+
+ expect(findAbuseCategorySelector().exists()).toBe(true);
+
+ findAbuseCategorySelector().vm.$emit('close-drawer');
+
+ await nextTick();
+
+ expect(findAbuseCategorySelector().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js
index efa08ced3ad..4bf7d0c57a3 100644
--- a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js
@@ -12,6 +12,7 @@ import WidgetWrapper from '~/work_items/components/widget_wrapper.vue';
import WorkItemLinks from '~/work_items/components/work_item_links/work_item_links.vue';
import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import { FORM_TYPES } from '~/work_items/constants';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import changeWorkItemParentMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
@@ -25,6 +26,7 @@ import {
changeWorkItemParentMutationResponse,
workItemQueryResponse,
projectWorkItemResponse,
+ mockWorkItemCommentNote,
} from '../../mock_data';
Vue.use(VueApollo);
@@ -76,6 +78,7 @@ describe('WorkItemLinks', () => {
provide: {
projectPath: 'project/path',
hasIterationsFeature,
+ reportAbusePath: '/report/abuse/path',
},
propsData: { issuableId: 1 },
apolloProvider: mockApollo,
@@ -105,6 +108,8 @@ describe('WorkItemLinks', () => {
const findFirstWorkItemLinkChild = () => findWorkItemLinkChildItems().at(0);
const findAddLinksForm = () => wrapper.findByTestId('add-links-form');
const findChildrenCount = () => wrapper.findByTestId('children-count');
+ const findWorkItemDetailModal = () => wrapper.findComponent(WorkItemDetailModal);
+ const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector);
afterEach(() => {
mockApollo = null;
@@ -328,7 +333,7 @@ describe('WorkItemLinks', () => {
await createComponent();
expect(showModal).not.toHaveBeenCalled();
- expect(wrapper.findComponent(WorkItemDetailModal).props('workItemIid')).toBe(null);
+ expect(findWorkItemDetailModal().props('workItemIid')).toBe(null);
});
it('opens the modal if work item iid URL parameter is found in child items', async () => {
@@ -336,6 +341,31 @@ describe('WorkItemLinks', () => {
await createComponent();
expect(showModal).toHaveBeenCalled();
- expect(wrapper.findComponent(WorkItemDetailModal).props('workItemIid')).toBe('2');
+ expect(findWorkItemDetailModal().props('workItemIid')).toBe('2');
+ });
+
+ describe('abuse category selector', () => {
+ beforeEach(async () => {
+ setWindowLocation('?work_item_id=2');
+ await createComponent();
+ });
+
+ it('should not be visible by default', () => {
+ expect(findAbuseCategorySelector().exists()).toBe(false);
+ });
+
+ it('should be visible when the work item modal emits `openReportAbuse` event', async () => {
+ findWorkItemDetailModal().vm.$emit('openReportAbuse', mockWorkItemCommentNote);
+
+ await nextTick();
+
+ expect(findAbuseCategorySelector().exists()).toBe(true);
+
+ findAbuseCategorySelector().vm.$emit('close-drawer');
+
+ await nextTick();
+
+ expect(findAbuseCategorySelector().exists()).toBe(false);
+ });
});
});
diff --git a/spec/frontend/work_items/components/work_item_notes_spec.js b/spec/frontend/work_items/components/work_item_notes_spec.js
index 3cc6a9813fc..7dbf828c44a 100644
--- a/spec/frontend/work_items/components/work_item_notes_spec.js
+++ b/spec/frontend/work_items/components/work_item_notes_spec.js
@@ -97,6 +97,7 @@ describe('WorkItemNotes component', () => {
workItemIid = mockWorkItemIid,
defaultWorkItemNotesQueryHandler = workItemNotesQueryHandler,
deleteWINoteMutationHandler = deleteWorkItemNoteMutationSuccessHandler,
+ isModal = false,
} = {}) => {
wrapper = shallowMount(WorkItemNotes, {
apolloProvider: createMockApollo([
@@ -116,6 +117,8 @@ describe('WorkItemNotes component', () => {
fullPath: 'test-path',
fetchByIid,
workItemType: 'task',
+ reportAbusePath: '/report/abuse/path',
+ isModal,
},
stubs: {
GlModal: stubComponent(GlModal, { methods: { show: showModal } }),
diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js
index 988fdc301de..86e890ea809 100644
--- a/spec/frontend/work_items/router_spec.js
+++ b/spec/frontend/work_items/router_spec.js
@@ -74,6 +74,7 @@ describe('Work items router', () => {
hasIterationsFeature: false,
hasOkrsFeature: false,
hasIssuableHealthStatusFeature: false,
+ reportAbusePath: '/report/abuse/path',
},
stubs: {
WorkItemWeight: true,
diff --git a/spec/helpers/sidebars_helper_spec.rb b/spec/helpers/sidebars_helper_spec.rb
index 6c0ac024944..5fbda3d77b0 100644
--- a/spec/helpers/sidebars_helper_spec.rb
+++ b/spec/helpers/sidebars_helper_spec.rb
@@ -401,7 +401,7 @@ RSpec.describe SidebarsHelper, feature_category: :navigation do
it 'returns public links and admin area link' do
expect(subject[:context_switcher_links]).to eq([
*public_link,
- { title: s_('Navigation|Admin'), link: '/admin', icon: 'admin' }
+ { title: s_('Navigation|Admin Area'), link: '/admin', icon: 'admin' }
])
end
end
diff --git a/spec/helpers/work_items_helper_spec.rb b/spec/helpers/work_items_helper_spec.rb
new file mode 100644
index 00000000000..4e1eca3d411
--- /dev/null
+++ b/spec/helpers/work_items_helper_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe WorkItemsHelper, feature_category: :team_planning do
+ describe '#work_items_index_data' do
+ subject(:work_items_index_data) { helper.work_items_index_data(project) }
+
+ let_it_be(:project) { build(:project) }
+
+ it 'returns the expected data properties' do
+ expect(work_items_index_data).to include(
+ {
+ full_path: project.full_path,
+ issues_list_path: project_issues_path(project),
+ register_path: new_user_registration_path(redirect_to_referer: 'yes'),
+ sign_in_path: user_session_path(redirect_to_referer: 'yes'),
+ new_comment_template_path: profile_comment_templates_path,
+ report_abuse_path: add_category_abuse_reports_path
+ }
+ )
+ end
+ end
+end
diff --git a/spec/initializers/active_record_transaction_observer_spec.rb b/spec/initializers/active_record_transaction_observer_spec.rb
new file mode 100644
index 00000000000..a834037dce5
--- /dev/null
+++ b/spec/initializers/active_record_transaction_observer_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'ActiveRecord Transaction Observer', feature_category: :application_performance do
+ def load_initializer
+ load Rails.root.join('config/initializers/active_record_transaction_observer.rb')
+ end
+
+ context 'when DBMS is available' do
+ before do
+ allow_next_instance_of(ActiveRecord::Base.connection) do |connection| # rubocop:disable Database/MultipleDatabases
+ allow(connection).to receive(:active?).and_return(true)
+ end
+ end
+
+ it 'calls Gitlab::Database::Transaction::Observer' do
+ allow(Feature::FlipperFeature).to receive(:table_exists?).and_return(true)
+
+ expect(Gitlab::Database::Transaction::Observer).to receive(:register!)
+
+ load_initializer
+ end
+
+ context 'when flipper table does not exist' do
+ before do
+ allow(Feature::FlipperFeature).to receive(:table_exists?).and_raise(ActiveRecord::NoDatabaseError)
+ end
+
+ it 'does not calls Gitlab::Database::Transaction::Observer' do
+ expect(Gitlab::Database::Transaction::Observer).not_to receive(:register!)
+
+ load_initializer
+ end
+ end
+ end
+
+ context 'when DBMS is not available' do
+ before do
+ allow(ActiveRecord::Base).to receive(:connection).and_raise(PG::ConnectionBad)
+ end
+
+ it 'does not calls Gitlab::Database::Transaction::Observer' do
+ expect(Gitlab::Database::Transaction::Observer).not_to receive(:register!)
+
+ load_initializer
+ end
+ end
+end
diff --git a/spec/lib/feature_spec.rb b/spec/lib/feature_spec.rb
index 51f21e7f46e..f0a017897c1 100644
--- a/spec/lib/feature_spec.rb
+++ b/spec/lib/feature_spec.rb
@@ -11,32 +11,6 @@ RSpec.describe Feature, stub_feature_flags: false, feature_category: :shared do
skip_feature_flags_yaml_validation
end
- describe '.feature_flags_available?' do
- it 'returns false on connection error' do
- expect(ActiveRecord::Base.connection).to receive(:active?).and_raise(PG::ConnectionBad) # rubocop:disable Database/MultipleDatabases
-
- expect(described_class.feature_flags_available?).to eq(false)
- end
-
- it 'returns false when connection is not active' do
- expect(ActiveRecord::Base.connection).to receive(:active?).and_return(false) # rubocop:disable Database/MultipleDatabases
-
- expect(described_class.feature_flags_available?).to eq(false)
- end
-
- it 'returns false when the flipper table does not exist' do
- expect(Feature::FlipperFeature).to receive(:table_exists?).and_return(false)
-
- expect(described_class.feature_flags_available?).to eq(false)
- end
-
- it 'returns false on NoDatabaseError' do
- expect(Feature::FlipperFeature).to receive(:table_exists?).and_raise(ActiveRecord::NoDatabaseError)
-
- expect(described_class.feature_flags_available?).to eq(false)
- end
- end
-
describe '.get' do
let(:feature) { double(:feature) }
let(:key) { 'my_feature' }
diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb
index 31258c42b5f..8e6aea96c58 100644
--- a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gitlab_redis_queues, :clean_gitlab_redis_shared_state do
+RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gitlab_redis_queues, :clean_gitlab_redis_shared_state,
+ feature_category: :shared do
using RSpec::Parameterized::TableSyntax
subject(:duplicate_job) do
@@ -63,6 +64,15 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi
it_behaves_like 'scheduling with deduplication class', 'None'
end
end
+
+ # This context is to be removed when FF `ci_pipeline_process_worker_dedup_until_executed` is removed
+ context 'when deduplication strategy is provided in the job options' do
+ before do
+ job['deduplicate'] = { 'strategy' => 'until_executed' }
+ end
+
+ it_behaves_like 'scheduling with deduplication class', 'UntilExecuted'
+ end
end
describe '#perform' do
@@ -480,6 +490,15 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi
expect(duplicate_job.options).to eq(worker_options)
end
+
+ # This context is to be removed when FF `ci_pipeline_process_worker_dedup_until_executed` is removed
+ context 'when deduplication options are provided in the job options' do
+ it "returns the job's deduplication options" do
+ job['deduplicate'] = { 'options' => { 'if_deduplicated' => 'reschedule_once', 'ttl' => '60' } }
+
+ expect(duplicate_job.options).to eq({ if_deduplicated: :reschedule_once, ttl: 60 })
+ end
+ end
end
describe '#idempotent?' do
diff --git a/spec/models/bulk_imports/file_transfer/group_config_spec.rb b/spec/models/bulk_imports/file_transfer/group_config_spec.rb
index 8660114b719..e50f52c728f 100644
--- a/spec/models/bulk_imports/file_transfer/group_config_spec.rb
+++ b/spec/models/bulk_imports/file_transfer/group_config_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkImports::FileTransfer::GroupConfig do
+RSpec.describe BulkImports::FileTransfer::GroupConfig, feature_category: :importers do
let_it_be(:exportable) { create(:group) }
let_it_be(:hex) { '123' }
@@ -49,4 +49,51 @@ RSpec.describe BulkImports::FileTransfer::GroupConfig do
expect(subject.relation_excluded_keys('group')).to include('owner_id')
end
end
+
+ describe '#batchable_relation?' do
+ context 'when relation is batchable' do
+ it 'returns true' do
+ expect(subject.batchable_relation?('labels')).to eq(true)
+ end
+ end
+
+ context 'when relation is not batchable' do
+ it 'returns false' do
+ expect(subject.batchable_relation?('namespace_settings')).to eq(false)
+ end
+ end
+
+ context 'when relation is not listed as portable' do
+ it 'returns false' do
+ expect(subject.batchable_relation?('foo')).to eq(false)
+ end
+ end
+ end
+
+ describe '#batchable_relations' do
+ it 'returns a list of collection associations for a group' do
+ expect(subject.batchable_relations).to include('labels', 'boards', 'milestones')
+ expect(subject.batchable_relations).not_to include('namespace_settings')
+ end
+ end
+
+ describe '#export_service_for' do
+ context 'when relation is a tree' do
+ it 'returns TreeExportService' do
+ expect(subject.export_service_for('labels')).to eq(BulkImports::TreeExportService)
+ end
+ end
+
+ context 'when relation is a file' do
+ it 'returns FileExportService' do
+ expect(subject.export_service_for('uploads')).to eq(BulkImports::FileExportService)
+ end
+ end
+
+ context 'when relation is unknown' do
+ it 'raises' do
+ expect { subject.export_service_for('foo') }.to raise_error(BulkImports::Error, 'Unsupported export relation')
+ end
+ end
+ end
end
diff --git a/spec/models/bulk_imports/file_transfer/project_config_spec.rb b/spec/models/bulk_imports/file_transfer/project_config_spec.rb
index 21fe6cfb3fa..014f624165c 100644
--- a/spec/models/bulk_imports/file_transfer/project_config_spec.rb
+++ b/spec/models/bulk_imports/file_transfer/project_config_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkImports::FileTransfer::ProjectConfig do
+RSpec.describe BulkImports::FileTransfer::ProjectConfig, feature_category: :importers do
let_it_be(:exportable) { create(:project) }
let_it_be(:hex) { '123' }
@@ -109,4 +109,31 @@ RSpec.describe BulkImports::FileTransfer::ProjectConfig do
expect(subject.file_relations).to contain_exactly('uploads', 'lfs_objects', 'repository', 'design')
end
end
+
+ describe '#batchable_relation?' do
+ context 'when relation is batchable' do
+ it 'returns true' do
+ expect(subject.batchable_relation?('issues')).to eq(true)
+ end
+ end
+
+ context 'when relation is not batchable' do
+ it 'returns false' do
+ expect(subject.batchable_relation?('project_feature')).to eq(false)
+ end
+ end
+
+ context 'when relation is not listed as portable' do
+ it 'returns false' do
+ expect(subject.batchable_relation?('foo')).to eq(false)
+ end
+ end
+ end
+
+ describe '#batchable_relations' do
+ it 'returns a list of collection associations for a project' do
+ expect(subject.batchable_relations).to include('issues', 'merge_requests', 'milestones')
+ expect(subject.batchable_relations).not_to include('project_feature', 'ci_cd_settings')
+ end
+ end
end
diff --git a/spec/services/bulk_imports/batched_relation_export_service_spec.rb b/spec/services/bulk_imports/batched_relation_export_service_spec.rb
new file mode 100644
index 00000000000..c361dfe5052
--- /dev/null
+++ b/spec/services/bulk_imports/batched_relation_export_service_spec.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::BatchedRelationExportService, feature_category: :importers do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:portable) { create(:group) }
+
+ let(:relation) { 'labels' }
+ let(:jid) { '123' }
+
+ subject(:service) { described_class.new(user, portable, relation, jid) }
+
+ describe '#execute' do
+ context 'when there are batches to export' do
+ let_it_be(:label) { create(:group_label, group: portable) }
+
+ it 'marks export as started' do
+ service.execute
+
+ export = portable.bulk_import_exports.first
+
+ expect(export.reload.started?).to eq(true)
+ end
+
+ it 'removes existing batches' do
+ expect_next_instance_of(BulkImports::Export) do |export|
+ expect(export.batches).to receive(:destroy_all)
+ end
+
+ service.execute
+ end
+
+ it 'enqueues export jobs for each batch & caches batch record ids' do
+ expect(BulkImports::RelationBatchExportWorker).to receive(:perform_async)
+ expect(Gitlab::Cache::Import::Caching).to receive(:set_add)
+
+ service.execute
+ end
+
+ it 'enqueues FinishBatchedRelationExportWorker' do
+ expect(BulkImports::FinishBatchedRelationExportWorker).to receive(:perform_async)
+
+ service.execute
+ end
+
+ context 'when there are multiple batches' do
+ it 'creates a batch record for each batch of records' do
+ stub_const("#{described_class.name}::BATCH_SIZE", 1)
+
+ create_list(:group_label, 10, group: portable)
+
+ service.execute
+
+ export = portable.bulk_import_exports.first
+
+ expect(export.batches.count).to eq(11)
+ end
+ end
+ end
+
+ context 'when there are no batches to export' do
+ let(:relation) { 'milestones' }
+
+ it 'marks export as finished' do
+ service.execute
+
+ export = portable.bulk_import_exports.first
+
+ expect(export.finished?).to eq(true)
+ expect(export.batches.count).to eq(0)
+ end
+ end
+
+ context 'when exception occurs' do
+ it 'tracks exception and marks export as failed' do
+ allow_next_instance_of(BulkImports::Export) do |export|
+ allow(export).to receive(:update!).and_call_original
+
+ allow(export)
+ .to receive(:update!)
+ .with(status_event: 'finish', total_objects_count: 0, batched: true, batches_count: 0, jid: jid, error: nil)
+ .and_raise(StandardError, 'Error!')
+ end
+
+ expect(Gitlab::ErrorTracking)
+ .to receive(:track_exception)
+ .with(StandardError, portable_id: portable.id, portable_type: portable.class.name)
+
+ service.execute
+
+ export = portable.bulk_import_exports.first
+
+ expect(export.reload.failed?).to eq(true)
+ end
+ end
+ end
+
+ describe '.cache_key' do
+ it 'returns cache key given export and batch ids' do
+ expect(described_class.cache_key(1, 1)).to eq('bulk_imports/batched_relation_export/1/1')
+ end
+ end
+end
diff --git a/spec/services/bulk_imports/export_service_spec.rb b/spec/services/bulk_imports/export_service_spec.rb
index ac7514fde5b..25a4547477c 100644
--- a/spec/services/bulk_imports/export_service_spec.rb
+++ b/spec/services/bulk_imports/export_service_spec.rb
@@ -13,17 +13,36 @@ RSpec.describe BulkImports::ExportService, feature_category: :importers do
subject { described_class.new(portable: group, user: user) }
describe '#execute' do
- it 'schedules RelationExportWorker for each top level relation' do
- expect(subject).to receive(:execute).and_return(ServiceResponse.success).and_call_original
- top_level_relations = BulkImports::FileTransfer.config_for(group).portable_relations
-
- top_level_relations.each do |relation|
- expect(BulkImports::RelationExportWorker)
- .to receive(:perform_async)
- .with(user.id, group.id, group.class.name, relation)
+ let_it_be(:top_level_relations) { BulkImports::FileTransfer.config_for(group).portable_relations }
+
+ before do
+ allow(subject).to receive(:execute).and_return(ServiceResponse.success).and_call_original
+ end
+
+ context 'when export is not batched' do
+ it 'schedules RelationExportWorker for each top level relation' do
+ top_level_relations.each do |relation|
+ expect(BulkImports::RelationExportWorker)
+ .to receive(:perform_async)
+ .with(user.id, group.id, group.class.name, relation, false)
+ end
+
+ subject.execute
end
+ end
+
+ context 'when export is batched' do
+ subject { described_class.new(portable: group, user: user, batched: true) }
- subject.execute
+ it 'schedules RelationExportWorker with a `batched: true` flag' do
+ top_level_relations.each do |relation|
+ expect(BulkImports::RelationExportWorker)
+ .to receive(:perform_async)
+ .with(user.id, group.id, group.class.name, relation, true)
+ end
+
+ subject.execute
+ end
end
context 'when exception occurs' do
@@ -38,6 +57,20 @@ RSpec.describe BulkImports::ExportService, feature_category: :importers do
service.execute
end
+
+ context 'when user is not allowed to perform export' do
+ let(:another_user) { create(:user) }
+
+ it 'does not schedule RelationExportWorker' do
+ another_user = create(:user)
+ service = described_class.new(portable: group, user: another_user)
+ response = service.execute
+
+ expect(response.status).to eq(:error)
+ expect(response.message).to eq(Gitlab::ImportExport::Error)
+ expect(response.http_status).to eq(:unprocessable_entity)
+ end
+ end
end
end
end
diff --git a/spec/services/bulk_imports/file_export_service_spec.rb b/spec/services/bulk_imports/file_export_service_spec.rb
index 3c23b86ad5c..001fccb2054 100644
--- a/spec/services/bulk_imports/file_export_service_spec.rb
+++ b/spec/services/bulk_imports/file_export_service_spec.rb
@@ -5,18 +5,20 @@ require 'spec_helper'
RSpec.describe BulkImports::FileExportService, feature_category: :importers do
let_it_be(:project) { create(:project) }
+ let(:relations) do
+ {
+ 'uploads' => BulkImports::UploadsExportService,
+ 'lfs_objects' => BulkImports::LfsObjectsExportService,
+ 'repository' => BulkImports::RepositoryBundleExportService,
+ 'design' => BulkImports::RepositoryBundleExportService
+ }
+ end
+
describe '#execute' do
it 'executes export service and archives exported data for each file relation' do
- relations = {
- 'uploads' => BulkImports::UploadsExportService,
- 'lfs_objects' => BulkImports::LfsObjectsExportService,
- 'repository' => BulkImports::RepositoryBundleExportService,
- 'design' => BulkImports::RepositoryBundleExportService
- }
-
relations.each do |relation, klass|
Dir.mktmpdir do |export_path|
- service = described_class.new(project, export_path, relation)
+ service = described_class.new(project, export_path, relation, nil)
expect_next_instance_of(klass) do |service|
expect(service).to receive(:execute)
@@ -31,18 +33,58 @@ RSpec.describe BulkImports::FileExportService, feature_category: :importers do
context 'when unsupported relation is passed' do
it 'raises an error' do
- service = described_class.new(project, nil, 'unsupported')
+ service = described_class.new(project, nil, 'unsupported', nil)
expect { service.execute }.to raise_error(BulkImports::Error, 'Unsupported relation export type')
end
end
end
+ describe '#execute_batch' do
+ it 'calls execute with provided array of record ids' do
+ relations.each do |relation, klass|
+ Dir.mktmpdir do |export_path|
+ service = described_class.new(project, export_path, relation, nil)
+
+ expect_next_instance_of(klass) do |service|
+ expect(service).to receive(:execute).with({ batch_ids: [1, 2, 3] })
+ end
+
+ service.export_batch([1, 2, 3])
+ end
+ end
+ end
+ end
+
describe '#exported_filename' do
it 'returns filename of the exported file' do
- service = described_class.new(project, nil, 'uploads')
+ service = described_class.new(project, nil, 'uploads', nil)
expect(service.exported_filename).to eq('uploads.tar')
end
end
+
+ describe '#exported_objects_count' do
+ context 'when relation is a collection' do
+ it 'returns a number of exported relations' do
+ %w[uploads lfs_objects].each do |relation|
+ service = described_class.new(project, nil, relation, nil)
+
+ allow(service).to receive_message_chain(:export_service, :exported_objects_count).and_return(10)
+
+ expect(service.exported_objects_count).to eq(10)
+ end
+ end
+ end
+
+ context 'when relation is a repository' do
+ it 'returns 1' do
+ %w[repository design].each do |relation|
+ service = described_class.new(project, nil, relation, nil)
+
+ expect(service.exported_objects_count).to eq(1)
+ end
+ end
+ end
+ end
end
diff --git a/spec/services/bulk_imports/lfs_objects_export_service_spec.rb b/spec/services/bulk_imports/lfs_objects_export_service_spec.rb
index 4f721a3a259..587c99d9897 100644
--- a/spec/services/bulk_imports/lfs_objects_export_service_spec.rb
+++ b/spec/services/bulk_imports/lfs_objects_export_service_spec.rb
@@ -53,6 +53,19 @@ RSpec.describe BulkImports::LfsObjectsExportService, feature_category: :importer
)
end
+ context 'when export is batched' do
+ it 'exports only specified lfs objects' do
+ new_lfs_object = create(:lfs_object, :with_file)
+
+ project.lfs_objects << new_lfs_object
+
+ service.execute(batch_ids: [new_lfs_object.id])
+
+ expect(File).to exist(File.join(export_path, new_lfs_object.oid))
+ expect(File).not_to exist(File.join(export_path, lfs_object.oid))
+ end
+ end
+
context 'when lfs object has file on disk missing' do
it 'does not attempt to copy non-existent file' do
FileUtils.rm(lfs_object.file.path)
@@ -79,4 +92,14 @@ RSpec.describe BulkImports::LfsObjectsExportService, feature_category: :importer
end
end
end
+
+ describe '#exported_objects_count' do
+ it 'return the number of exported lfs objects' do
+ project.lfs_objects << create(:lfs_object, :with_file)
+
+ service.execute
+
+ expect(service.exported_objects_count).to eq(2)
+ end
+ end
end
diff --git a/spec/services/bulk_imports/relation_batch_export_service_spec.rb b/spec/services/bulk_imports/relation_batch_export_service_spec.rb
new file mode 100644
index 00000000000..c3abd02aff8
--- /dev/null
+++ b/spec/services/bulk_imports/relation_batch_export_service_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::RelationBatchExportService, feature_category: :importers do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:label) { create(:label, project: project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:export) { create(:bulk_import_export, :batched, project: project) }
+ let_it_be(:batch) { create(:bulk_import_export_batch, export: export) }
+ let_it_be(:cache_key) { BulkImports::BatchedRelationExportService.cache_key(export.id, batch.id) }
+
+ subject(:service) { described_class.new(user.id, batch.id) }
+
+ before(:all) do
+ Gitlab::Cache::Import::Caching.set_add(cache_key, label.id)
+ end
+
+ after(:all) do
+ Gitlab::Cache::Import::Caching.expire(cache_key, 0)
+ end
+
+ describe '#execute' do
+ it 'exports relation batch' do
+ expect(Gitlab::Cache::Import::Caching).to receive(:values_from_set).with(cache_key).and_call_original
+
+ service.execute
+ batch.reload
+
+ expect(batch.finished?).to eq(true)
+ expect(batch.objects_count).to eq(1)
+ expect(batch.error).to be_nil
+ expect(export.upload.export_file).to be_present
+ end
+
+ it 'removes exported contents after export' do
+ double = instance_double(BulkImports::FileTransfer::ProjectConfig, export_path: 'foo')
+
+ allow(BulkImports::FileTransfer).to receive(:config_for).and_return(double)
+ allow(double).to receive(:export_service_for).and_raise(StandardError, 'Error!')
+ allow(FileUtils).to receive(:remove_entry)
+
+ expect(FileUtils).to receive(:remove_entry).with('foo')
+
+ service.execute
+ end
+
+ context 'when exception occurs' do
+ before do
+ allow(service).to receive(:gzip).and_raise(StandardError, 'Error!')
+ end
+
+ it 'marks batch as failed' do
+ expect(Gitlab::ErrorTracking)
+ .to receive(:track_exception)
+ .with(StandardError, portable_id: project.id, portable_type: 'Project')
+
+ service.execute
+ batch.reload
+
+ expect(batch.failed?).to eq(true)
+ expect(batch.objects_count).to eq(0)
+ expect(batch.error).to eq('Error!')
+ end
+ end
+ end
+end
diff --git a/spec/services/bulk_imports/relation_export_service_spec.rb b/spec/services/bulk_imports/relation_export_service_spec.rb
index bc999b0b9b3..1c050fe4143 100644
--- a/spec/services/bulk_imports/relation_export_service_spec.rb
+++ b/spec/services/bulk_imports/relation_export_service_spec.rb
@@ -35,6 +35,10 @@ RSpec.describe BulkImports::RelationExportService, feature_category: :importers
expect(export.reload.upload.export_file).to be_present
expect(export.finished?).to eq(true)
+ expect(export.batched?).to eq(false)
+ expect(export.batches_count).to eq(0)
+ expect(export.batches.count).to eq(0)
+ expect(export.total_objects_count).to eq(0)
end
it 'removes temp export files' do
@@ -133,13 +137,23 @@ RSpec.describe BulkImports::RelationExportService, feature_category: :importers
include_examples 'tracks exception', ActiveRecord::RecordInvalid
end
+ end
+
+ context 'when export was batched' do
+ let(:relation) { 'milestones' }
+ let(:export) { create(:bulk_import_export, group: group, relation: relation, batched: true, batches_count: 2) }
- context 'when user is not allowed to perform export' do
- let(:another_user) { create(:user) }
+ it 'removes existing batches and marks export as not batched' do
+ create(:bulk_import_export_batch, batch_number: 1, export: export)
+ create(:bulk_import_export_batch, batch_number: 2, export: export)
- subject { described_class.new(another_user, group, relation, jid) }
+ expect { described_class.new(user, group, relation, jid).execute }
+ .to change { export.reload.batches.count }
+ .from(2)
+ .to(0)
- include_examples 'tracks exception', Gitlab::ImportExport::Error
+ expect(export.batched?).to eq(false)
+ expect(export.batches_count).to eq(0)
end
end
end
diff --git a/spec/services/bulk_imports/tree_export_service_spec.rb b/spec/services/bulk_imports/tree_export_service_spec.rb
index fa96641f1c1..ae78858976f 100644
--- a/spec/services/bulk_imports/tree_export_service_spec.rb
+++ b/spec/services/bulk_imports/tree_export_service_spec.rb
@@ -53,4 +53,14 @@ RSpec.describe BulkImports::TreeExportService, feature_category: :importers do
end
end
end
+
+ describe '#export_batch' do
+ it 'serializes relation with specified ids' do
+ expect_next_instance_of(Gitlab::ImportExport::Json::StreamingSerializer) do |serializer|
+ expect(serializer).to receive(:serialize_relation).with(anything, batch_ids: [1, 2, 3])
+ end
+
+ subject.export_batch([1, 2, 3])
+ end
+ end
end
diff --git a/spec/services/bulk_imports/uploads_export_service_spec.rb b/spec/services/bulk_imports/uploads_export_service_spec.rb
index 8dc67b28d12..709ade4a504 100644
--- a/spec/services/bulk_imports/uploads_export_service_spec.rb
+++ b/spec/services/bulk_imports/uploads_export_service_spec.rb
@@ -3,9 +3,8 @@
require 'spec_helper'
RSpec.describe BulkImports::UploadsExportService, feature_category: :importers do
- let_it_be(:export_path) { Dir.mktmpdir }
- let_it_be(:project) { create(:project, avatar: fixture_file_upload('spec/fixtures/rails_sample.png', 'image/png')) }
-
+ let(:export_path) { Dir.mktmpdir }
+ let(:project) { create(:project, avatar: fixture_file_upload('spec/fixtures/rails_sample.png', 'image/png')) }
let!(:upload) { create(:upload, :with_file, :issuable_upload, uploader: FileUploader, model: project) }
let(:exported_filepath) { File.join(export_path, upload.secret, upload.retrieve_uploader.filename) }
@@ -23,6 +22,16 @@ RSpec.describe BulkImports::UploadsExportService, feature_category: :importers d
expect(File).to exist(exported_filepath)
end
+ context 'when export is batched' do
+ it 'exports only specified uploads' do
+ service.execute(batch_ids: [upload.id])
+
+ expect(service.exported_objects_count).to eq(1)
+ expect(File).not_to exist(File.join(export_path, 'avatar', 'rails_sample.png'))
+ expect(File).to exist(exported_filepath)
+ end
+ end
+
context 'when upload has underlying file missing' do
context 'with an upload missing its file' do
it 'does not cause errors' do
@@ -53,6 +62,16 @@ RSpec.describe BulkImports::UploadsExportService, feature_category: :importers d
}
)
+ expect(Gitlab::ErrorTracking)
+ .to receive(:log_exception)
+ .with(
+ instance_of(exception), {
+ portable_id: project.id,
+ portable_class: 'Project',
+ upload_id: project.avatar.upload.id
+ }
+ )
+
service.execute
expect(File).not_to exist(exported_filepath)
@@ -73,4 +92,12 @@ RSpec.describe BulkImports::UploadsExportService, feature_category: :importers d
end
end
end
+
+ describe '#exported_objects_count' do
+ it 'return the number of exported uploads' do
+ service.execute
+
+ expect(service.exported_objects_count).to eq(2)
+ end
+ end
end
diff --git a/spec/services/system_notes/time_tracking_service_spec.rb b/spec/services/system_notes/time_tracking_service_spec.rb
index 5cc17f55012..71228050085 100644
--- a/spec/services/system_notes/time_tracking_service_spec.rb
+++ b/spec/services/system_notes/time_tracking_service_spec.rb
@@ -37,7 +37,7 @@ RSpec.describe ::SystemNotes::TimeTrackingService, feature_category: :team_plann
end
it 'sets the correct note message' do
- expect(note.note).to eq('removed start date and removed due date')
+ expect(note.note).to eq("removed start date #{start_date.to_s(:long)} and removed due date #{due_date.to_s(:long)}")
end
end
@@ -52,7 +52,7 @@ RSpec.describe ::SystemNotes::TimeTrackingService, feature_category: :team_plann
let(:changed_dates) { { 'due_date' => [nil, due_date], 'start_date' => [start_date, nil] } }
it 'sets the correct note message' do
- expect(note.note).to eq("removed start date and changed due date to #{due_date.to_s(:long)}")
+ expect(note.note).to eq("removed start date #{start_date.to_s(:long)} and changed due date to #{due_date.to_s(:long)}")
end
end
end
@@ -80,7 +80,7 @@ RSpec.describe ::SystemNotes::TimeTrackingService, feature_category: :team_plann
let(:changed_dates) { { 'due_date' => [due_date, nil], 'start_date' => [nil, start_date] } }
it 'sets the correct note message' do
- expect(note.note).to eq("changed start date to #{start_date.to_s(:long)} and removed due date")
+ expect(note.note).to eq("changed start date to #{start_date.to_s(:long)} and removed due date #{due_date.to_s(:long)}")
end
end
end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index c3bddf1a6ae..334c709dcf8 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -311,10 +311,6 @@ RSpec.configure do |config|
# See https://docs.gitlab.com/ee/development/feature_flags/#selectively-disable-by-actor
stub_feature_flags(legacy_merge_request_state_check_for_merged_result_pipelines: false)
- # Disable the `vue_issues_dashboard` feature flag in specs as we migrate the issues
- # dashboard page to Vue. https://gitlab.com/gitlab-org/gitlab/-/issues/379025
- stub_feature_flags(vue_issues_dashboard: false)
-
allow(Gitlab::GitalyClient).to receive(:can_use_disk?).and_return(enable_rugged)
else
unstub_all_feature_flags
diff --git a/spec/support/helpers/filtered_search_helpers.rb b/spec/support/helpers/filtered_search_helpers.rb
index b07f5dcf2e1..ecc749b1e45 100644
--- a/spec/support/helpers/filtered_search_helpers.rb
+++ b/spec/support/helpers/filtered_search_helpers.rb
@@ -69,12 +69,6 @@ module FilteredSearchHelpers
filtered_search.send_keys(:enter)
end
- def init_label_search
- filtered_search.set('label:=')
- # This ensures the dropdown is shown
- expect(find('#js-dropdown-label')).not_to have_css('.filter-dropdown-loading')
- end
-
def expect_filtered_search_input_empty
expect(find('.filtered-search').value).to eq('')
end
diff --git a/spec/views/projects/issues/_related_issues.html.haml_spec.rb b/spec/views/projects/issues/_related_issues.html.haml_spec.rb
new file mode 100644
index 00000000000..0dbca032c4b
--- /dev/null
+++ b/spec/views/projects/issues/_related_issues.html.haml_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'projects/issues/_related_issues.html.haml', feature_category: :team_planning do
+ let_it_be(:project) { build_stubbed(:project) }
+ let_it_be(:issue) { build_stubbed(:issue, project: project) }
+
+ context 'when current user cannot read issue link for the project' do
+ before do
+ allow(view).to receive(:can?).and_return(false)
+ end
+
+ it 'does not render the related issues root node' do
+ render
+
+ expect(rendered).not_to have_selector(".js-related-issues-root")
+ end
+ end
+
+ context 'when current user can read issue link for the project' do
+ before do
+ allow(view).to receive(:can?).and_return(true)
+
+ assign(:project, project)
+ assign(:issue, issue)
+ end
+
+ it 'adds the report abuse path as a data attribute' do
+ render
+
+ expect(rendered).to have_selector(
+ ".js-related-issues-root[data-report-abuse-path=\"#{add_category_abuse_reports_path}\"]"
+ )
+ end
+ end
+end
diff --git a/spec/workers/bulk_imports/finish_batched_relation_export_worker_spec.rb b/spec/workers/bulk_imports/finish_batched_relation_export_worker_spec.rb
new file mode 100644
index 00000000000..6fbcb267c0a
--- /dev/null
+++ b/spec/workers/bulk_imports/finish_batched_relation_export_worker_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::FinishBatchedRelationExportWorker, feature_category: :importers do
+ let(:export) { create(:bulk_import_export, :started) }
+ let(:batch) { create(:bulk_import_export_batch, :finished, export: export) }
+ let(:export_id) { export.id }
+ let(:job_args) { [export_id] }
+
+ describe '#perform' do
+ it_behaves_like 'an idempotent worker' do
+ it 'marks export as finished and expires batches cache' do
+ cache_key = BulkImports::BatchedRelationExportService.cache_key(export.id, batch.id)
+
+ expect(Gitlab::Cache::Import::Caching).to receive(:expire).with(cache_key, 0)
+
+ perform_multiple(job_args)
+
+ expect(export.reload.finished?).to eq(true)
+ end
+
+ context 'when export is finished' do
+ let(:export) { create(:bulk_import_export, :finished) }
+
+ it 'returns without updating export' do
+ perform_multiple(job_args)
+
+ expect(export.reload.finished?).to eq(true)
+ end
+ end
+
+ context 'when export is failed' do
+ let(:export) { create(:bulk_import_export, :failed) }
+
+ it 'returns without updating export' do
+ perform_multiple(job_args)
+
+ expect(export.reload.failed?).to eq(true)
+ end
+ end
+
+ context 'when export is in progress' do
+ it 'reenqueues itself' do
+ create(:bulk_import_export_batch, :started, export: export)
+
+ expect(described_class).to receive(:perform_in).twice
+
+ perform_multiple(job_args)
+
+ expect(export.reload.started?).to eq(true)
+ end
+ end
+
+ context 'when export timed out' do
+ it 'marks export as failed' do
+ expect(export.reload.failed?).to eq(false)
+ expect(batch.reload.failed?).to eq(false)
+
+ export.update!(updated_at: 1.day.ago)
+
+ perform_multiple(job_args)
+
+ expect(export.reload.failed?).to eq(true)
+ expect(batch.reload.failed?).to eq(true)
+ end
+ end
+
+ context 'when export is missing' do
+ let(:export_id) { nil }
+
+ it 'returns' do
+ expect(described_class).not_to receive(:perform_in)
+
+ perform_multiple(job_args)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/workers/bulk_imports/relation_batch_export_worker_spec.rb b/spec/workers/bulk_imports/relation_batch_export_worker_spec.rb
new file mode 100644
index 00000000000..4a2c8d48742
--- /dev/null
+++ b/spec/workers/bulk_imports/relation_batch_export_worker_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::RelationBatchExportWorker, feature_category: :importers do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:batch) { create(:bulk_import_export_batch) }
+
+ let(:job_args) { [user.id, batch.id] }
+
+ describe '#perform' do
+ include_examples 'an idempotent worker' do
+ it 'executes RelationBatchExportService' do
+ service = instance_double(BulkImports::RelationBatchExportService)
+
+ expect(BulkImports::RelationBatchExportService)
+ .to receive(:new)
+ .with(user.id, batch.id)
+ .twice.and_return(service)
+ expect(service).to receive(:execute).twice
+
+ perform_multiple(job_args)
+ end
+ end
+ end
+end
diff --git a/spec/workers/bulk_imports/relation_export_worker_spec.rb b/spec/workers/bulk_imports/relation_export_worker_spec.rb
index c2f7831896b..38ef4df263e 100644
--- a/spec/workers/bulk_imports/relation_export_worker_spec.rb
+++ b/spec/workers/bulk_imports/relation_export_worker_spec.rb
@@ -4,17 +4,18 @@ require 'spec_helper'
RSpec.describe BulkImports::RelationExportWorker, feature_category: :importers do
let_it_be(:jid) { 'jid' }
- let_it_be(:relation) { 'labels' }
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
- let(:job_args) { [user.id, group.id, group.class.name, relation] }
+ let(:batched) { false }
+ let(:relation) { 'labels' }
+ let(:job_args) { [user.id, group.id, group.class.name, relation, batched] }
describe '#perform' do
include_examples 'an idempotent worker' do
context 'when export record does not exist' do
let(:another_group) { create(:group) }
- let(:job_args) { [user.id, another_group.id, another_group.class.name, relation] }
+ let(:job_args) { [user.id, another_group.id, another_group.class.name, relation, batched] }
it 'creates export record' do
another_group.add_owner(user)
@@ -26,21 +27,37 @@ RSpec.describe BulkImports::RelationExportWorker, feature_category: :importers d
end
end
- it 'executes RelationExportService' do
- group.add_owner(user)
+ shared_examples 'export service' do |export_service|
+ it 'executes export service' do
+ group.add_owner(user)
- service = instance_double(BulkImports::RelationExportService)
+ service = instance_double(export_service)
- expect(BulkImports::RelationExportService)
- .to receive(:new)
- .with(user, group, relation, anything)
- .twice
- .and_return(service)
- expect(service)
- .to receive(:execute)
- .twice
+ expect(export_service)
+ .to receive(:new)
+ .with(user, group, relation, anything)
+ .twice
+ .and_return(service)
+ expect(service).to receive(:execute).twice
- perform_multiple(job_args)
+ perform_multiple(job_args)
+ end
+ end
+
+ context 'when export is batched' do
+ let(:batched) { true }
+
+ include_examples 'export service', BulkImports::BatchedRelationExportService
+
+ context 'when relation is not batchable' do
+ let(:relation) { 'namespace_settings' }
+
+ include_examples 'export service', BulkImports::RelationExportService
+ end
+ end
+
+ context 'when export is not batched' do
+ include_examples 'export service', BulkImports::RelationExportService
end
end
end
diff --git a/spec/workers/pipeline_process_worker_spec.rb b/spec/workers/pipeline_process_worker_spec.rb
index 6c6851c51ce..1c76cdca347 100644
--- a/spec/workers/pipeline_process_worker_spec.rb
+++ b/spec/workers/pipeline_process_worker_spec.rb
@@ -5,6 +5,48 @@ require 'spec_helper'
RSpec.describe PipelineProcessWorker, feature_category: :continuous_integration do
let_it_be(:pipeline) { create(:ci_pipeline) }
+ # The two examples below are to be added when FF `ci_pipeline_process_worker_dedup_until_executed` is removed
+ # it 'has the `until_executed` deduplicate strategy' do
+ # expect(described_class.get_deduplicate_strategy).to eq(:until_executed)
+ # end
+
+ # it 'has the option to reschedule once if deduplicated and a TTL of 1 minute' do
+ # expect(described_class.get_deduplication_options).to include({ if_deduplicated: :reschedule_once, ttl: 1.minute })
+ # end
+
+ # This context is to be removed when FF `ci_pipeline_process_worker_dedup_until_executed` is removed
+ describe '#perform_async', :sidekiq_inline do
+ around do |example|
+ Sidekiq::Testing.fake! { example.run }
+ end
+
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:pipeline) { create(:ci_empty_pipeline, project: project) }
+
+ subject { described_class.perform_async(pipeline.id) }
+
+ it 'sets the deduplication settings in the job options' do
+ subject
+
+ job = described_class.jobs.last
+ expect(job['deduplicate']).to eq({ 'strategy' => 'until_executed',
+ 'options' => { 'if_deduplicated' => 'reschedule_once', 'ttl' => '60' } })
+ end
+
+ context 'when FF `ci_pipeline_process_worker_dedup_until_executed` is disabled' do
+ before do
+ stub_feature_flags(ci_pipeline_process_worker_dedup_until_executed: false)
+ end
+
+ it 'does not set the deduplication settings in the job options' do
+ subject
+
+ job = described_class.jobs.last
+ expect(job['deduplicate']).to be_nil
+ end
+ end
+ end
+
include_examples 'an idempotent worker' do
let(:pipeline) { create(:ci_pipeline, :created) }
let(:job_args) { [pipeline.id] }
diff --git a/yarn.lock b/yarn.lock
index 09e8ef8fb44..ceb4171621d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1115,10 +1115,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-3.40.0.tgz#f1ebb2fcdbb1181550d53f0db827eca1f5060af0"
integrity sha512-9CVkIbV0VnIFfVBjWcW8+nHzpMhHhC73C9mGPEktEPfpEbaaRws2UywgDEH+C2B8Ba1QdBo/aFr68RDu2VwvfA==
-"@gitlab/ui@61.1.1":
- version "61.1.1"
- resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-61.1.1.tgz#fcd0f66e307456aacfb072d026bf9096bef43658"
- integrity sha512-dd9LqCV/6Ju7pMId0g8vbTyHYa1MavVbvB2WsJpnr+VsCPCLsl8hp9XB238kijhvfQqcExUsqwm0Plsu/nE1xQ==
+"@gitlab/ui@61.3.0":
+ version "61.3.0"
+ resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-61.3.0.tgz#5b1f9c7a01d7571150a796ca5f02fe44f9604fc4"
+ integrity sha512-jOaEGjvtrVT7IZhtu/VZNmlodGgrE/UllciNfJxzm4nbbWMHlALspFp8btt/zxGxiI33oQJLS2qTCEu0QVVZHA==
dependencies:
"@popperjs/core" "^2.11.2"
bootstrap-vue "2.23.1"