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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.rubocop_todo/rspec/context_wording.yml1
-rw-r--r--.rubocop_todo/style/symbol_proc.yml1
-rw-r--r--app/assets/javascripts/invite_members/components/import_project_members_modal.vue144
-rw-r--r--app/assets/javascripts/vue_shared/components/actions_button.vue74
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue65
-rw-r--r--app/models/project_team.rb6
-rw-r--r--app/services/members/import_project_team_service.rb81
-rw-r--r--app/views/projects/blob/_breadcrumb.html.haml2
-rw-r--r--app/views/projects/blob/show.html.haml2
-rw-r--r--app/views/projects/find_file/show.html.haml4
-rw-r--r--doc/api/projects.md27
-rw-r--r--doc/development/database/batched_background_migrations.md437
-rw-r--r--doc/development/features_inside_dot_gitlab.md2
-rw-r--r--doc/topics/offline/quick_start_guide.md85
-rw-r--r--doc/user/analytics/img/dora_performers_score_panel_v16_3.pngbin0 -> 43006 bytes
-rw-r--r--doc/user/analytics/value_streams_dashboard.md12
-rw-r--r--doc/user/project/repository/branches/default.md2
-rw-r--r--lib/api/projects.rb14
-rw-r--r--locale/gitlab.pot91
-rw-r--r--qa/qa/page/file/show.rb2
-rw-r--r--spec/features/projects/members/import_project_members_spec.rb79
-rw-r--r--spec/features/projects/members/user_manages_project_members_spec.rb (renamed from spec/features/projects/settings/user_manages_project_members_spec.rb)23
-rw-r--r--spec/frontend/invite_members/components/import_project_members_modal_spec.js95
-rw-r--r--spec/frontend/invite_members/mock_data/api_responses.js24
-rw-r--r--spec/frontend/vue_shared/components/actions_button_spec.js119
-rw-r--r--spec/frontend/vue_shared/components/web_ide_link_spec.js74
-rw-r--r--spec/models/project_team_spec.rb9
-rw-r--r--spec/requests/api/projects_spec.rb18
-rw-r--r--spec/services/members/import_project_team_service_spec.rb93
-rw-r--r--spec/support/rspec_order_todo.yml2
30 files changed, 1010 insertions, 578 deletions
diff --git a/.rubocop_todo/rspec/context_wording.yml b/.rubocop_todo/rspec/context_wording.yml
index c1751ebed4c..c34185e4e0b 100644
--- a/.rubocop_todo/rspec/context_wording.yml
+++ b/.rubocop_todo/rspec/context_wording.yml
@@ -689,7 +689,6 @@ RSpec/ContextWording:
- 'ee/spec/services/ee/keys/destroy_service_spec.rb'
- 'ee/spec/services/ee/members/create_service_spec.rb'
- 'ee/spec/services/ee/members/destroy_service_spec.rb'
- - 'ee/spec/services/ee/members/import_project_team_service_spec.rb'
- 'ee/spec/services/ee/members/invite_service_spec.rb'
- 'ee/spec/services/ee/merge_requests/base_service_spec.rb'
- 'ee/spec/services/ee/merge_requests/create_service_spec.rb'
diff --git a/.rubocop_todo/style/symbol_proc.yml b/.rubocop_todo/style/symbol_proc.yml
index 67229a8b704..a8f966d2266 100644
--- a/.rubocop_todo/style/symbol_proc.yml
+++ b/.rubocop_todo/style/symbol_proc.yml
@@ -13,7 +13,6 @@ Style/SymbolProc:
- 'app/models/members/project_member.rb'
- 'app/models/namespace.rb'
- 'app/models/preloaders/merge_request_diff_preloader.rb'
- - 'app/models/project_team.rb'
- 'app/models/release.rb'
- 'app/models/remote_mirror.rb'
- 'app/models/snippet_input_action_collection.rb'
diff --git a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
index 66d4a9ccc07..5599ad276f0 100644
--- a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
@@ -1,5 +1,5 @@
<script>
-import { GlFormGroup, GlModal, GlSprintf } from '@gitlab/ui';
+import { GlFormGroup, GlModal, GlSprintf, GlAlert, GlCollapse, GlIcon, GlButton } from '@gitlab/ui';
import { uniqueId, isEmpty } from 'lodash';
import { importProjectMembers } from '~/api/projects_api';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
@@ -16,8 +16,10 @@ import {
PROJECT_SELECT_LABEL_ID,
IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_CATEGORY,
IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_LABEL,
+ MEMBER_MODAL_LABELS,
} from '../constants';
+import { responseFromSuccess } from '../utils/response_message_parser';
import UserLimitNotification from './user_limit_notification.vue';
import ProjectSelect from './project_select.vue';
@@ -27,6 +29,10 @@ export default {
GlFormGroup,
GlModal,
GlSprintf,
+ GlAlert,
+ GlCollapse,
+ GlIcon,
+ GlButton,
UserLimitNotification,
ProjectSelect,
},
@@ -60,6 +66,9 @@ export default {
return {
projectToBeImported: {},
invalidFeedbackMessage: '',
+ totalMembersCount: 0,
+ invalidMembers: {},
+ isErrorsSectionExpanded: false,
isLoading: false,
};
},
@@ -94,6 +103,40 @@ export default {
actionCancel() {
return { text: this.$options.i18n.modalCancelButton };
},
+ hasInvalidMembers() {
+ return !isEmpty(this.invalidMembers);
+ },
+ memberErrorTitle() {
+ return sprintf(
+ s__(
+ 'InviteMembersModal|The following %{errorMembersLength} out of %{totalMembersCount} members could not be added',
+ ),
+ { errorMembersLength: this.errorList.length, totalMembersCount: this.totalMembersCount },
+ );
+ },
+ errorList() {
+ return Object.entries(this.invalidMembers).map(([member, error]) => {
+ return { member, displayedMemberName: `@${member}`, message: error };
+ });
+ },
+ errorsLimited() {
+ return this.errorList.slice(0, this.$options.errorsLimit);
+ },
+ errorsExpanded() {
+ return this.errorList.slice(this.$options.errorsLimit);
+ },
+ shouldErrorsSectionExpand() {
+ return Boolean(this.errorsExpanded.length);
+ },
+ errorCollapseText() {
+ if (this.isErrorsSectionExpanded) {
+ return this.$options.labels.expandedErrors;
+ }
+
+ return sprintf(this.$options.labels.collapsedErrors, {
+ count: this.errorsExpanded.length,
+ });
+ },
},
mounted() {
if (this.reloadPageOnSubmit) {
@@ -113,21 +156,37 @@ export default {
this.$root.$emit(BV_HIDE_MODAL, this.$options.modalId);
},
resetFields() {
+ this.clearValidation();
this.invalidFeedbackMessage = '';
this.projectToBeImported = {};
},
- submitImport(e) {
+ async submitImport(event) {
// We never want to hide when submitting
- e.preventDefault();
+ event.preventDefault();
this.isLoading = true;
- return importProjectMembers(this.projectId, this.projectToBeImported.id)
- .then(this.onInviteSuccess)
- .catch(this.showErrorAlert)
- .finally(() => {
- this.isLoading = false;
- this.projectToBeImported = {};
- });
+
+ try {
+ const response = await importProjectMembers(this.projectId, this.projectToBeImported.id);
+
+ const { error, message } = responseFromSuccess(response);
+
+ if (error) {
+ this.totalMembersCount = response.data.total_members_count;
+ this.showMemberErrors(message);
+ } else {
+ this.onInviteSuccess();
+ }
+ } catch {
+ this.showErrorAlert();
+ } finally {
+ this.isLoading = false;
+ this.projectToBeImported = {};
+ }
+ },
+ showMemberErrors(message) {
+ this.invalidMembers = message;
+ this.$refs.alerts.focus();
},
onInviteSuccess() {
this.track('invite_successful');
@@ -151,6 +210,13 @@ export default {
onClose() {
this.track('click_x');
},
+ clearValidation() {
+ this.invalidFeedbackMessage = '';
+ this.invalidMembers = {};
+ },
+ toggleErrorExpansion() {
+ this.isErrorsSectionExpanded = !this.isErrorsSectionExpanded;
+ },
},
toastOptions() {
return {
@@ -173,8 +239,10 @@ export default {
defaultError: s__('ImportAProjectModal|Unable to import project members'),
successMessage: s__('ImportAProjectModal|Successfully imported'),
},
+ errorsLimit: 2,
projectSelectLabelId: PROJECT_SELECT_LABEL_ID,
modalId: uniqueId('import-a-project-modal-'),
+ labels: MEMBER_MODAL_LABELS,
};
</script>
@@ -186,18 +254,62 @@ export default {
:title="$options.i18n.modalTitle"
:action-primary="actionPrimary"
:action-cancel="actionCancel"
+ data-testid="import-project-members-modal"
no-focus-on-show
@primary="submitImport"
@hidden="resetFields"
@cancel="onCancel"
@close="onClose"
>
- <user-limit-notification
- v-if="showUserLimitNotification"
- class="gl-mb-5"
- :limit-variant="limitVariant"
- :users-limit-dataset="usersLimitDataset"
- />
+ <div ref="alerts" tabindex="-1">
+ <gl-alert
+ v-if="hasInvalidMembers"
+ class="gl-mb-4"
+ variant="danger"
+ :dismissible="false"
+ :title="memberErrorTitle"
+ data-testid="alert-member-error"
+ >
+ {{ $options.labels.memberErrorListText }}
+ <ul class="gl-pl-5 gl-mb-0">
+ <li v-for="error in errorsLimited" :key="error.member" data-testid="errors-limited-item">
+ <strong>{{ error.displayedMemberName }}:</strong> {{ error.message }}
+ </li>
+ </ul>
+ <template v-if="shouldErrorsSectionExpand">
+ <gl-collapse v-model="isErrorsSectionExpanded">
+ <ul class="gl-pl-5 gl-mb-0">
+ <li
+ v-for="error in errorsExpanded"
+ :key="error.member"
+ data-testid="errors-expanded-item"
+ >
+ <strong>{{ error.displayedMemberName }}:</strong> {{ error.message }}
+ </li>
+ </ul>
+ </gl-collapse>
+ <gl-button
+ class="gl-text-decoration-none! gl-shadow-none! gl-mt-3"
+ data-testid="accordion-button"
+ variant="link"
+ @click="toggleErrorExpansion"
+ >
+ {{ errorCollapseText }}
+ <gl-icon
+ name="chevron-down"
+ class="gl-transition-medium"
+ :class="{ 'gl-rotate-180': isErrorsSectionExpanded }"
+ />
+ </gl-button>
+ </template>
+ </gl-alert>
+ <user-limit-notification
+ v-else-if="showUserLimitNotification"
+ class="gl-mb-5"
+ :limit-variant="limitVariant"
+ :users-limit-dataset="usersLimitDataset"
+ />
+ </div>
<p ref="modalIntro">
<gl-sprintf :message="modalIntro">
<template #strong="{ content }">
diff --git a/app/assets/javascripts/vue_shared/components/actions_button.vue b/app/assets/javascripts/vue_shared/components/actions_button.vue
deleted file mode 100644
index 1d6dbef799a..00000000000
--- a/app/assets/javascripts/vue_shared/components/actions_button.vue
+++ /dev/null
@@ -1,74 +0,0 @@
-<script>
-import {
- GlDisclosureDropdown,
- GlDisclosureDropdownGroup,
- GlDisclosureDropdownItem,
-} from '@gitlab/ui';
-
-export default {
- components: {
- GlDisclosureDropdown,
- GlDisclosureDropdownGroup,
- GlDisclosureDropdownItem,
- },
- props: {
- toggleText: {
- type: String,
- required: true,
- },
- actions: {
- type: Array,
- required: true,
- },
- category: {
- type: String,
- required: false,
- default: 'secondary',
- },
- variant: {
- type: String,
- required: false,
- default: 'default',
- },
- },
- methods: {
- handleItemClick(action) {
- return action.handle?.();
- },
- },
-};
-</script>
-
-<template>
- <gl-disclosure-dropdown
- :variant="variant"
- :category="category"
- :toggle-text="toggleText"
- data-qa-selector="action_dropdown"
- fluid-width
- block
- @shown="$emit('shown')"
- @hidden="$emit('hidden')"
- >
- <gl-disclosure-dropdown-group class="edit-dropdown-group-width">
- <gl-disclosure-dropdown-item
- v-for="action in actions"
- :key="action.key"
- v-bind="action.attrs"
- :item="action"
- :data-qa-selector="`${action.key}_menu_item`"
- @action="handleItemClick(action)"
- >
- <template #list-item>
- <div class="gl-display-flex gl-flex-direction-column">
- <span class="gl-font-weight-bold gl-mb-2">{{ action.text }}</span>
- <span class="gl-text-gray-700">
- {{ action.secondaryText }}
- </span>
- </div>
- </template>
- </gl-disclosure-dropdown-item>
- </gl-disclosure-dropdown-group>
- <slot></slot>
- </gl-disclosure-dropdown>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
index 9a06c0ecf30..756d9b42e99 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -1,8 +1,14 @@
<script>
-import { GlModal, GlSprintf, GlLink } from '@gitlab/ui';
+import {
+ GlModal,
+ GlSprintf,
+ GlLink,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { visitUrl } from '~/lib/utils/url_utility';
-import ActionsButton from '~/vue_shared/components/actions_button.vue';
import ConfirmForkModal from '~/vue_shared/components/web_ide/confirm_fork_modal.vue';
import { KEY_EDIT, KEY_WEB_IDE, KEY_GITPOD, KEY_PIPELINE_EDITOR } from './constants';
@@ -25,10 +31,12 @@ export const i18n = {
export default {
name: 'CEWebIdeLink',
components: {
- ActionsButton,
GlModal,
GlSprintf,
GlLink,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
ConfirmForkModal,
},
i18n,
@@ -174,7 +182,6 @@ export default {
text: __('Edit single file'),
secondaryText: __('Edit this file only.'),
attrs: {
- 'data-qa-selector': 'edit_button',
'data-track-action': 'click_consolidated_edit',
'data-track-label': 'edit',
},
@@ -217,7 +224,6 @@ export default {
text: this.webIdeActionText,
secondaryText: this.$options.i18n.webIdeText,
attrs: {
- 'data-qa-selector': 'web_ide_button',
'data-track-action': 'click_consolidated_edit_ide',
'data-track-label': 'web_ide',
},
@@ -246,10 +252,11 @@ export default {
key: KEY_PIPELINE_EDITOR,
text: __('Edit in pipeline editor'),
secondaryText,
+ href: this.pipelineEditorUrl,
attrs: {
- 'data-qa-selector': 'pipeline_editor_button',
+ 'data-track-action': 'click_consolidated_pipeline_editor',
+ 'data-track-label': 'pipeline_editor',
},
- href: this.pipelineEditorUrl,
};
},
gitpodAction() {
@@ -270,9 +277,6 @@ export default {
key: KEY_GITPOD,
text: this.gitpodActionText,
secondaryText,
- attrs: {
- 'data-qa-selector': 'gitpod_button',
- },
...handleOptions,
};
},
@@ -306,25 +310,50 @@ export default {
showModal(dataKey) {
this[dataKey] = true;
},
+ executeAction(action) {
+ action.handle?.();
+ },
},
- webIdeButtonId: 'web-ide-link',
};
</script>
<template>
<div class="gl-sm-ml-3">
- <actions-button
+ <gl-disclosure-dropdown
v-if="hasActions"
- :id="$options.webIdeButtonId"
- :actions="actions"
- :toggle-text="$options.i18n.toggleText"
:variant="isBlob ? 'confirm' : 'default'"
:category="isBlob ? 'primary' : 'secondary'"
- @hidden="$emit('hidden')"
+ :toggle-text="$options.i18n.toggleText"
+ data-qa-selector="action_dropdown"
+ fluid-width
+ block
@shown="$emit('shown')"
+ @hidden="$emit('hidden')"
>
- <slot></slot>
- </actions-button>
+ <slot name="before-actions"></slot>
+ <gl-disclosure-dropdown-group class="edit-dropdown-group-width">
+ <gl-disclosure-dropdown-item
+ v-for="action in actions"
+ :key="action.key"
+ v-bind="action.attrs"
+ :item="action"
+ :data-qa-selector="`${action.key}_menu_item`"
+ @action="executeAction(action)"
+ >
+ <template #list-item>
+ <div class="gl-display-flex gl-flex-direction-column">
+ <span data-testid="action-primary-text" class="gl-font-weight-bold gl-mb-2">{{
+ action.text
+ }}</span>
+ <span data-testid="action-secondary-text" class="gl-text-gray-700">
+ {{ action.secondaryText }}
+ </span>
+ </div>
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown-group>
+ <slot name="after-actions"></slot>
+ </gl-disclosure-dropdown>
<gl-modal
v-if="computedShowGitpodButton && !gitpodEnabled"
v-model="showEnableGitpodModal"
diff --git a/app/models/project_team.rb b/app/models/project_team.rb
index fbdc88e7b76..3b9b82ee094 100644
--- a/app/models/project_team.rb
+++ b/app/models/project_team.rb
@@ -141,12 +141,10 @@ class ProjectTeam
end
ProjectMember.transaction do
- source_members.each do |member|
- member.save
- end
+ source_members.each(&:save)
end
- true
+ source_members
rescue StandardError
false
end
diff --git a/app/services/members/import_project_team_service.rb b/app/services/members/import_project_team_service.rb
index 6efd65e2575..ef43d8206a9 100644
--- a/app/services/members/import_project_team_service.rb
+++ b/app/services/members/import_project_team_service.rb
@@ -2,36 +2,83 @@
module Members
class ImportProjectTeamService < BaseService
- attr_reader :params, :current_user
+ ImportProjectTeamForbiddenError = Class.new(StandardError)
- def target_project_id
- @target_project_id ||= params[:id].presence
+ def initialize(*args)
+ super
+
+ @errors = {}
end
- def source_project_id
- @source_project_id ||= params[:project_id].presence
+ def execute
+ check_target_and_source_projects_exist!
+ check_user_permissions!
+
+ import_project_team
+ process_import_result
+
+ result
+ rescue ArgumentError, ImportProjectTeamForbiddenError => e
+ ServiceResponse.error(message: e.message, reason: :unprocessable_entity)
end
- def target_project
- @target_project ||= Project.find_by_id(target_project_id)
+ private
+
+ attr_reader :members, :params, :current_user, :errors, :result
+
+ def import_project_team
+ @members = target_project.team.import(source_project, current_user)
+
+ if members.is_a?(Array)
+ members.each { |member| check_member_validity(member) }
+ else
+ @result = ServiceResponse.error(message: 'Import failed', reason: :unprocessable_entity)
+ end
end
- def source_project
- @source_project ||= Project.find_by_id(source_project_id)
+ def check_target_and_source_projects_exist!
+ if target_project.blank?
+ raise ArgumentError, 'Target project does not exist'
+ elsif source_project.blank?
+ raise ArgumentError, 'Source project does not exist'
+ end
end
- def execute
- import_project_team
+ def check_user_permissions!
+ return if can?(current_user, :read_project_member, source_project) &&
+ can?(current_user, :import_project_members_from_another_project, target_project)
+
+ raise ImportProjectTeamForbiddenError, 'Forbidden'
end
- private
+ def check_member_validity(member)
+ return if member.valid?
- def import_project_team
- return false unless target_project.present? && source_project.present? && current_user.present?
- return false unless can?(current_user, :read_project_member, source_project)
- return false unless can?(current_user, :import_project_members_from_another_project, target_project)
+ errors[member.user.username] = member.errors.full_messages.to_sentence
+ end
+
+ def process_import_result
+ @result ||= if errors.any?
+ ServiceResponse.error(message: errors, payload: { total_members_count: members.size })
+ else
+ ServiceResponse.success(message: 'Successfully imported')
+ end
+ end
+
+ def target_project_id
+ params[:id]
+ end
- target_project.team.import(source_project, current_user)
+ def source_project_id
+ params[:project_id]
+ end
+
+ def target_project
+ @target_project ||= Project.find_by_id(target_project_id)
+ end
+
+ def source_project
+ @source_project ||= Project.find_by_id(source_project_id)
end
end
end
diff --git a/app/views/projects/blob/_breadcrumb.html.haml b/app/views/projects/blob/_breadcrumb.html.haml
index 417c11ba37a..3178426ee8b 100644
--- a/app/views/projects/blob/_breadcrumb.html.haml
+++ b/app/views/projects/blob/_breadcrumb.html.haml
@@ -1,7 +1,7 @@
- blame = local_assigns.fetch(:blame, false)
.nav-block
.tree-ref-container
- .tree-ref-holder
+ .tree-ref-holder.gl-max-w-26
#js-tree-ref-switcher{ data: { project_id: @project.id, project_root_path: project_path(@project), ref: current_ref, ref_type: @ref_type.to_s } }
%ul.breadcrumb.repo-breadcrumb
diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml
index c8cf12c36f9..1ffcb11247b 100644
--- a/app/views/projects/blob/show.html.haml
+++ b/app/views/projects/blob/show.html.haml
@@ -10,7 +10,7 @@
= render 'projects/last_push'
-#tree-holder.tree-holder
+#tree-holder.tree-holder.gl-pt-4
= render 'blob', blob: @blob
= render partial: 'pipeline_tour_success' if show_suggest_pipeline_creation_celebration?
diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml
index 7e93e44c463..541b8c1147d 100644
--- a/app/views/projects/find_file/show.html.haml
+++ b/app/views/projects/find_file/show.html.haml
@@ -1,9 +1,9 @@
- page_title _("Find File"), @ref
- add_page_specific_style 'page_bundles/tree'
-.file-finder-holder.tree-holder.clearfix.js-file-finder{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, format: :json))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @ref)) }
+.file-finder-holder.tree-holder.clearfix.js-file-finder.gl-pt-4{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, format: :json))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @ref)) }
.nav-block.gl-xs-mr-0
- .tree-ref-holder.gl-xs-mb-3.gl-xs-w-full
+ .tree-ref-holder.gl-xs-mb-3.gl-xs-w-full.gl-max-w-26
#js-blob-ref-switcher{ data: { project_id: @project.id, ref: @ref, namespace: "/-/find_file" } }
%ul.breadcrumb.repo-breadcrumb.gl-flex-nowrap
%li.breadcrumb-item.gl-white-space-nowrap
diff --git a/doc/api/projects.md b/doc/api/projects.md
index 6afed915135..813d0fe35cb 100644
--- a/doc/api/projects.md
+++ b/doc/api/projects.md
@@ -2647,6 +2647,33 @@ Returns:
- `404 Project Not Found` if the target or source project does not exist or cannot be accessed by the requester.
- `422 Unprocessable Entity` if the import of project members does not complete successfully.
+Example responses:
+
+When all emails were successfully sent (`200` HTTP status code):
+
+```json
+{ "status": "success" }
+```
+
+When there was any error importing 1 or more members (`200` HTTP status code):
+
+```json
+{
+ "status": "error",
+ "message": {
+ "john_smith": "Some individual error message",
+ "jane_smith": "Some individual error message"
+ },
+ "total_members_count": 3
+}
+```
+
+When there is a system error (`404` and `422` HTTP status codes):
+
+```json
+{ "message": "Import failed" }
+```
+
## Hooks
Also called Project Hooks and Webhooks. These are different for [System Hooks](system_hooks.md)
diff --git a/doc/development/database/batched_background_migrations.md b/doc/development/database/batched_background_migrations.md
index 1bdd24afcad..10490df7b5e 100644
--- a/doc/development/database/batched_background_migrations.md
+++ b/doc/development/database/batched_background_migrations.md
@@ -13,6 +13,13 @@ in our guidelines. For example, you can use batched background
migrations to migrate data that's stored in a single JSON column
to a separate table instead.
+NOTE:
+Batched background migrations replaced the legacy background migrations framework.
+Check that documentation in reference to any changes involving that framework.
+
+NOTE:
+The batched background migrations framework has ChatOps support. Using ChatOps, GitLab engineers can interact with the batched background migrations present in the system.
+
## When to use batched background migrations
Use a batched background migration when you migrate _data_ in tables containing
@@ -201,6 +208,13 @@ models defined in `app/models` except the `ApplicationRecord` classes).
Because these migrations can take a long time to run, it's possible
for new versions to deploy while the migrations are still running.
+### Depending on migrated data
+
+Unlike a regular or a post migration, waiting for the next release is not enough to guarantee that the data was fully migrated.
+That means that you shouldn't depend on the data until the BBM is finished. If having 100% of the data migrated is a requirement,
+then, the `ensure_batched_background_migration_is_finished` helper can be used to guarantee that the migration was finished and the
+data fully migrated. ([See an example](https://gitlab.com/gitlab-org/gitlab/-/blob/41fbe34a4725a4e357a83fda66afb382828767b2/db/post_migrate/20210707210916_finalize_ci_stages_bigint_conversion.rb#L13-18)).
+
## How to
### Generate a batched background migration
@@ -538,6 +552,107 @@ Bumping the [import/export version](../../user/project/settings/import_export.md
be required, if importing a project from a prior version of GitLab requires the
data to be in the new format.
+### Add indexes to support batched background migrations
+
+Sometimes it is necessary to add a new or temporary index to support a batched background migration.
+To do this, create the index in a post-deployment migration that precedes the post-deployment
+migration that queues the background migration.
+
+See the documentation for [adding database indexes](adding_database_indexes.md#analyzing-a-new-index-before-a-batched-background-migration)
+for additional information about some cases that require special attention to allow the index to be used directly after
+creation.
+
+### Execute a particular batch on the database testing pipeline
+
+NOTE:
+Only [database maintainers](https://gitlab.com/groups/gitlab-org/maintainers/database/-/group_members?with_inherited_permissions=exclude) can view the database testing pipeline artifacts. Ask one for help if you need to use this method.
+
+Let's assume that a batched background migration failed on a particular batch on GitLab.com and you want to figure out which query failed and why. At the moment, we don't have a good way to retrieve query information (especially the query parameters) and rerunning the entire migration with more logging would be a long process.
+
+Fortunately you can leverage our [database migration pipeline](database_migration_pipeline.md) to rerun a particular batch with additional logging and/or fix to see if it solves the problem.
+
+For an example see [Draft: `Test PG::CardinalityViolation` fix](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/110910) but make sure to read the entire section.
+
+To do that, you need to:
+
+1. [Find the batch `start_id` and `end_id`](#find-the-batch-start_id-and-end_id)
+1. [Create a regular migration](#create-a-regular-migration)
+1. [Apply a workaround for our migration helpers](#apply-a-workaround-for-our-migration-helpers-optional) (optional)
+1. [Start the database migration pipeline](#start-the-database-migration-pipeline)
+
+#### Find the batch `start_id` and `end_id`
+
+You should be able to find those in [Kibana](#viewing-failure-error-logs).
+
+#### Create a regular migration
+
+Schedule the batch in the `up` block of a regular migration:
+
+```ruby
+def up
+ instance = Gitlab::BackgroundMigration::YourBackgroundMigrationClass.new(
+ start_id: <batch start_id>,
+ end_id: <batch end_id>,
+ batch_table: <table name>,
+ batch_column: <batching column>,
+ sub_batch_size: <sub batch size>,
+ pause_ms: <miliseconds between batches>,
+ job_arguments: <job arguments if any>,
+ connection: connection
+ )
+
+ instance.perform
+end
+
+def down
+ # no-op
+end
+```
+
+#### Apply a workaround for our migration helpers (optional)
+
+If your batched background migration touches tables from a schema other than the one you specified by using `restrict_gitlab_migration` helper (example: the scheduling migration has `restrict_gitlab_migration gitlab_schema: :gitlab_main` but the background job uses tables from the `:gitlab_ci` schema) then the migration will fail. To prevent that from happening you must to monkey patch database helpers so they don't fail the testing pipeline job:
+
+1. Add the schema names to [`RestrictGitlabSchema`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/database/migration_helpers/restrict_gitlab_schema.rb#L57)
+
+```diff
+diff --git a/lib/gitlab/database/migration_helpers/restrict_gitlab_schema.rb b/lib/gitlab/database/migration_helpers/restrict_gitlab_schema.rb
+index b8d1d21a0d2d2a23d9e8c8a0a17db98ed1ed40b7..912e20659a6919f771045178c66828563cb5a4a1 100644
+--- a/lib/gitlab/database/migration_helpers/restrict_gitlab_schema.rb
++++ b/lib/gitlab/database/migration_helpers/restrict_gitlab_schema.rb
+@@ -55,7 +55,7 @@ def unmatched_schemas
+ end
+
+ def allowed_schemas_for_connection
+- Gitlab::Database.gitlab_schemas_for_connection(connection)
++ Gitlab::Database.gitlab_schemas_for_connection(connection) << :gitlab_ci
+ end
+ end
+ end
+```
+
+1. Add the schema names to [`RestrictAllowedSchemas`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/database/query_analyzers/restrict_allowed_schemas.rb#L82)
+
+```diff
+diff --git a/lib/gitlab/database/query_analyzers/restrict_allowed_schemas.rb b/lib/gitlab/database/query_analyzers/restrict_allowed_schemas.rb
+index 4ae3622479f0800c0553959e132143ec9051898e..d556ec7f55adae9d46a56665ce02de782cb09f2d 100644
+--- a/lib/gitlab/database/query_analyzers/restrict_allowed_schemas.rb
++++ b/lib/gitlab/database/query_analyzers/restrict_allowed_schemas.rb
+@@ -79,7 +79,7 @@ def restrict_to_dml_only(parsed)
+ tables = self.dml_tables(parsed)
+ schemas = self.dml_schemas(tables)
+
+- if (schemas - self.allowed_gitlab_schemas).any?
++ if (schemas - (self.allowed_gitlab_schemas << :gitlab_ci)).any?
+ raise DMLAccessDeniedError, \
+ "Select/DML queries (SELECT/UPDATE/DELETE) do access '#{tables}' (#{schemas.to_a}) " \
+ "which is outside of list of allowed schemas: '#{self.allowed_gitlab_schemas}'. " \
+```
+
+#### Start the database migration pipeline
+
+Create a Draft merge request with your changes and trigger the manual `db:gitlabcom-database-testing` job.
+
## Managing
NOTE:
@@ -683,22 +798,109 @@ migration is scheduled in the GitLab FOSS context.
You can use the [generator](#generate-a-batched-background-migration) to generate an EE-only migration scaffold by passing
`--ee-only` flag when generating a new batched background migration.
-## Throttling batched migrations
+## Debug
+
+### Viewing failure error logs
-Because batched migrations are update-heavy and there were few incidents in the past because of the heavy load from migrations while the database was underperforming, a throttling mechanism exists to mitigate them.
+You can view failures in two ways:
-These database indicators are checked to throttle a migration. On getting a
-stop signal, the migration is paused for a set time (10 minutes):
+- Via GitLab logs:
+ 1. After running a batched background migration, if any jobs fail,
+ view the logs in [Kibana](https://log.gprd.gitlab.net/goto/4cb43f40-f861-11ec-b86b-d963a1a6788e).
+ View the production Sidekiq log and filter for:
-- WAL queue pending archival crossing a threshold.
-- Active autovacuum on the tables on which the migration works on.
-- Patroni apdex SLI dropping below the SLO.
+ - `json.new_state: failed`
+ - `json.job_class_name: <Batched Background Migration job class name>`
+ - `json.job_arguments: <Batched Background Migration job class arguments>`
-It's an ongoing effort to add more indicators to further enhance the
-database health check framework. For more details, see
-[epic 7594](https://gitlab.com/groups/gitlab-org/-/epics/7594).
+ 1. Review the `json.exception_class` and `json.exception_message` values to help
+ understand why the jobs failed.
+
+ 1. Remember the retry mechanism. Having a failure does not mean the job failed.
+ Always check the last status of the job.
+
+- Via database:
+
+ 1. Get the batched background migration `CLASS_NAME`.
+ 1. Execute the following query in the PostgreSQL console:
+
+ ```sql
+ SELECT migration.id, migration.job_class_name, transition_logs.exception_class, transition_logs.exception_message
+ FROM batched_background_migrations as migration
+ INNER JOIN batched_background_migration_jobs as jobs
+ ON jobs.batched_background_migration_id = migration.id
+ INNER JOIN batched_background_migration_job_transition_logs as transition_logs
+ ON transition_logs.batched_background_migration_job_id = jobs.id
+ WHERE transition_logs.next_status = '2' AND migration.job_class_name = "CLASS_NAME";
+ ```
+
+## Testing
+
+Writing tests is required for:
+
+- The batched background migrations' queueing migration.
+- The batched background migration itself.
+- A cleanup migration.
+
+The `:migration` and `schema: :latest` RSpec tags are automatically set for
+background migration specs. Refer to the
+[Testing Rails migrations](../testing_guide/testing_migrations_guide.md#testing-a-non-activerecordmigration-class)
+style guide.
+
+Remember that `before` and `after` RSpec hooks
+migrate your database down and up. These hooks can result in other batched background
+migrations being called. Using `spy` test doubles with
+`have_received` is encouraged, instead of using regular test doubles, because
+your expectations defined in a `it` block can conflict with what is
+called in RSpec hooks. Refer to [issue #35351](https://gitlab.com/gitlab-org/gitlab/-/issues/18839)
+for more details.
+
+## Best practices
+
+1. Know how much data you're dealing with.
+1. Make sure the batched background migration jobs are idempotent.
+1. Confirm the tests you write are not false positives.
+1. If the data being migrated is critical and cannot be lost, the
+ clean-up migration must also check the final state of the data before completing.
+1. Discuss the numbers with a database specialist. The migration may add
+ more pressure on DB than you expect. Measure on staging,
+ or ask someone to measure on production.
+1. Know how much time is required to run the batched background migration.
+1. Be careful when silently rescuing exceptions inside job classes. This may lead to
+ jobs being marked as successful, even in a failure scenario.
+
+ ```ruby
+ # good
+ def perform
+ each_sub_batch do |sub_batch|
+ sub_batch.update_all(name: 'My Name')
+ end
+ end
+
+ # acceptable
+ def perform
+ each_sub_batch do |sub_batch|
+ sub_batch.update_all(name: 'My Name')
+ rescue Exception => error
+ logger.error(message: error.message, class: error.class)
+
+ raise
+ end
+ end
+
+ # bad
+ def perform
+ each_sub_batch do |sub_batch|
+ sub_batch.update_all(name: 'My Name')
+ rescue Exception => error
+ logger.error(message: error.message, class: self.class.name)
+ end
+ end
+ ```
+
+## Examples
-## Example
+### Routes use-case
The `routes` table has a `source_type` field that's used for a polymorphic relationship.
As part of a database redesign, we're removing the polymorphic relationship. One step of
@@ -867,216 +1069,3 @@ background migration.
After the batched migration is completed, you can safely depend on the
data in `routes.namespace_id` being populated.
-
-## Testing
-
-Writing tests is required for:
-
-- The batched background migrations' queueing migration.
-- The batched background migration itself.
-- A cleanup migration.
-
-The `:migration` and `schema: :latest` RSpec tags are automatically set for
-background migration specs. Refer to the
-[Testing Rails migrations](../testing_guide/testing_migrations_guide.md#testing-a-non-activerecordmigration-class)
-style guide.
-
-Remember that `before` and `after` RSpec hooks
-migrate your database down and up. These hooks can result in other batched background
-migrations being called. Using `spy` test doubles with
-`have_received` is encouraged, instead of using regular test doubles, because
-your expectations defined in a `it` block can conflict with what is
-called in RSpec hooks. Refer to [issue #35351](https://gitlab.com/gitlab-org/gitlab/-/issues/18839)
-for more details.
-
-## Best practices
-
-1. Know how much data you're dealing with.
-1. Make sure the batched background migration jobs are idempotent.
-1. Confirm the tests you write are not false positives.
-1. If the data being migrated is critical and cannot be lost, the
- clean-up migration must also check the final state of the data before completing.
-1. Discuss the numbers with a database specialist. The migration may add
- more pressure on DB than you expect. Measure on staging,
- or ask someone to measure on production.
-1. Know how much time is required to run the batched background migration.
-1. Be careful when silently rescuing exceptions inside job classes. This may lead to
- jobs being marked as successful, even in a failure scenario.
-
- ```ruby
- # good
- def perform
- each_sub_batch do |sub_batch|
- sub_batch.update_all(name: 'My Name')
- end
- end
-
- # acceptable
- def perform
- each_sub_batch do |sub_batch|
- sub_batch.update_all(name: 'My Name')
- rescue Exception => error
- logger.error(message: error.message, class: error.class)
-
- raise
- end
- end
-
- # bad
- def perform
- each_sub_batch do |sub_batch|
- sub_batch.update_all(name: 'My Name')
- rescue Exception => error
- logger.error(message: error.message, class: self.class.name)
- end
- end
- ```
-
-## Additional tips and strategies
-
-### ChatOps integration
-
-The batched background migrations framework has ChatOps support. Using ChatOps, GitLab engineers can interact with the batched background migrations present in the system.
-
-### Viewing failure error logs
-
-You can view failures in two ways:
-
-- Via GitLab logs:
- 1. After running a batched background migration, if any jobs fail,
- view the logs in [Kibana](https://log.gprd.gitlab.net/goto/4cb43f40-f861-11ec-b86b-d963a1a6788e).
- View the production Sidekiq log and filter for:
-
- - `json.new_state: failed`
- - `json.job_class_name: <Batched Background Migration job class name>`
- - `json.job_arguments: <Batched Background Migration job class arguments>`
-
- 1. Review the `json.exception_class` and `json.exception_message` values to help
- understand why the jobs failed.
-
- 1. Remember the retry mechanism. Having a failure does not mean the job failed.
- Always check the last status of the job.
-
-- Via database:
-
- 1. Get the batched background migration `CLASS_NAME`.
- 1. Execute the following query in the PostgreSQL console:
-
- ```sql
- SELECT migration.id, migration.job_class_name, transition_logs.exception_class, transition_logs.exception_message
- FROM batched_background_migrations as migration
- INNER JOIN batched_background_migration_jobs as jobs
- ON jobs.batched_background_migration_id = migration.id
- INNER JOIN batched_background_migration_job_transition_logs as transition_logs
- ON transition_logs.batched_background_migration_job_id = jobs.id
- WHERE transition_logs.next_status = '2' AND migration.job_class_name = "CLASS_NAME";
- ```
-
-### Executing a particular batch on the database testing pipeline
-
-NOTE:
-Only [database maintainers](https://gitlab.com/groups/gitlab-org/maintainers/database/-/group_members?with_inherited_permissions=exclude) can view the database testing pipeline artifacts. Ask one for help if you need to use this method.
-
-Let's assume that a batched background migration failed on a particular batch on GitLab.com and you want to figure out which query failed and why. At the moment, we don't have a good way to retrieve query information (especially the query parameters) and rerunning the entire migration with more logging would be a long process.
-
-Fortunately you can leverage our [database migration pipeline](database_migration_pipeline.md) to rerun a particular batch with additional logging and/or fix to see if it solves the problem.
-
-<!-- vale gitlab.Substitutions = NO -->
-
-For an example see [Draft: Test PG::CardinalityViolation fix](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/110910) but make sure to read the entire section.
-
-To do that, you need to:
-
-1. Find the batch `start_id` and `end_id`
-1. Create a regular migration
-1. Apply a workaround for our migration helpers (optional)
-1. Start the database migration pipeline
-
-#### 1. Find the batch `start_id` and `end_id`
-
-You should be able to find those in [Kibana](#viewing-failure-error-logs).
-
-#### 2. Create a regular migration
-
-Schedule the batch in the `up` block of a regular migration:
-
-```ruby
-def up
- instance = Gitlab::BackgroundMigration::YourBackgroundMigrationClass.new(
- start_id: <batch start_id>,
- end_id: <batch end_id>,
- batch_table: <table name>,
- batch_column: <batching column>,
- sub_batch_size: <sub batch size>,
- pause_ms: <miliseconds between batches>,
- job_arguments: <job arguments if any>,
- connection: connection
- )
-
- instance.perform
-end
-
-
-def down
- # no-op
-end
-```
-
-#### 3. Apply a workaround for our migration helpers (optional)
-
-If your batched background migration touches tables from a schema other than the one you specified by using `restrict_gitlab_migration` helper (example: the scheduling migration has `restrict_gitlab_migration gitlab_schema: :gitlab_main` but the background job uses tables from the `:gitlab_ci` schema) then the migration will fail. To prevent that from happening you must to monkey patch database helpers so they don't fail the testing pipeline job:
-
-1. Add the schema names to [`RestrictGitlabSchema`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/database/migration_helpers/restrict_gitlab_schema.rb#L57)
-
-```diff
-diff --git a/lib/gitlab/database/migration_helpers/restrict_gitlab_schema.rb b/lib/gitlab/database/migration_helpers/restrict_gitlab_schema.rb
-index b8d1d21a0d2d2a23d9e8c8a0a17db98ed1ed40b7..912e20659a6919f771045178c66828563cb5a4a1 100644
---- a/lib/gitlab/database/migration_helpers/restrict_gitlab_schema.rb
-+++ b/lib/gitlab/database/migration_helpers/restrict_gitlab_schema.rb
-@@ -55,7 +55,7 @@ def unmatched_schemas
- end
-
- def allowed_schemas_for_connection
-- Gitlab::Database.gitlab_schemas_for_connection(connection)
-+ Gitlab::Database.gitlab_schemas_for_connection(connection) << :gitlab_ci
- end
- end
- end
-```
-
-1. Add the schema names to [`RestrictAllowedSchemas`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/database/query_analyzers/restrict_allowed_schemas.rb#L82)
-
-```diff
-diff --git a/lib/gitlab/database/query_analyzers/restrict_allowed_schemas.rb b/lib/gitlab/database/query_analyzers/restrict_allowed_schemas.rb
-index 4ae3622479f0800c0553959e132143ec9051898e..d556ec7f55adae9d46a56665ce02de782cb09f2d 100644
---- a/lib/gitlab/database/query_analyzers/restrict_allowed_schemas.rb
-+++ b/lib/gitlab/database/query_analyzers/restrict_allowed_schemas.rb
-@@ -79,7 +79,7 @@ def restrict_to_dml_only(parsed)
- tables = self.dml_tables(parsed)
- schemas = self.dml_schemas(tables)
-
-- if (schemas - self.allowed_gitlab_schemas).any?
-+ if (schemas - (self.allowed_gitlab_schemas << :gitlab_ci)).any?
- raise DMLAccessDeniedError, \
- "Select/DML queries (SELECT/UPDATE/DELETE) do access '#{tables}' (#{schemas.to_a}) " \
- "which is outside of list of allowed schemas: '#{self.allowed_gitlab_schemas}'. " \
-```
-
-#### 4. Start the database migration pipeline
-
-Create a Draft merge request with your changes and trigger the manual `db:gitlabcom-database-testing` job.
-
-### Adding indexes to support batched background migrations
-
-Sometimes it is necessary to add a new or temporary index to support a batched background migration.
-To do this, create the index in a post-deployment migration that precedes the post-deployment
-migration that queues the background migration.
-
-See the documentation for [adding database indexes](adding_database_indexes.md#analyzing-a-new-index-before-a-batched-background-migration)
-for additional information about some cases that require special attention to allow the index to be used directly after
-creation.
-
-## Legacy background migrations
-
-Batched background migrations replaced the legacy background migrations framework.
-Check that documentation in reference to any changes involving that framework.
diff --git a/doc/development/features_inside_dot_gitlab.md b/doc/development/features_inside_dot_gitlab.md
index f55d7e52fbc..b7eda423584 100644
--- a/doc/development/features_inside_dot_gitlab.md
+++ b/doc/development/features_inside_dot_gitlab.md
@@ -17,3 +17,5 @@ When implementing new features, please refer to these existing features to avoid
- [Customize Auto DevOps Helm Values](../topics/autodevops/customize.md#customize-helm-chart-values): `.gitlab/auto-deploy-values.yaml`.
- [Insights](../user/project/insights/index.md#configure-project-insights): `.gitlab/insights.yml`.
- [Service Desk Templates](../user/project/service_desk.md#customize-emails-sent-to-the-requester): `.gitlab/service_desk_templates/`.
+- [Secret Detection Custom Rulesets](../user/application_security/secret_detection/index.md#disable-predefined-analyzer-rules): `.gitlab/secret-detection-ruleset.toml`
+- [Static Analysis Custom Rulesets](../user/application_security/sast/customize_rulesets.md#create-the-configuration-file): `.gitlab/sast-ruleset.toml`
diff --git a/doc/topics/offline/quick_start_guide.md b/doc/topics/offline/quick_start_guide.md
index dd739fdaf77..82a88b53dcf 100644
--- a/doc/topics/offline/quick_start_guide.md
+++ b/doc/topics/offline/quick_start_guide.md
@@ -215,15 +215,22 @@ On offline instances, the [GitLab Geo check Rake task](../../administration/geo/
always fails because it uses `pool.ntp.org`. This error can be ignored but you can
[read more about how to work around it](../../administration/geo/replication/troubleshooting.md#message-machine-clock-is-synchronized--exception).
-## Enabling the package metadata database
+## Enabling the Package Metadata Database
-Enabling the package metadata database is required to enable [license scanning of CycloneDX files](../../user/compliance/license_scanning_of_cyclonedx_files).
-This process requires usage of the GitLab License Database, which is licensed under the [EE License](https://storage.googleapis.com/prod-export-license-bucket-1a6c642fc4de57d4/v1/LICENSE).
-Note the following in relation to use of the License Database:
+Enabling the Package Metadata Database is required to enable [license scanning of CycloneDX files](../../user/compliance/license_scanning_of_cyclonedx_files).
+This process requires the use of License and/or Advisory Data under what is collectively called the Package Metadata Database, which is licensed under the [EE License](https://storage.googleapis.com/prod-export-license-bucket-1a6c642fc4de57d4/LICENSE).
+Note the following in relation to use of the Package Metadata Database:
-- We may change or discontinue all or any part of the License Database, at any time and without notice, at our sole discretion.
-- The License Database may contain links to third-party websites or resources. We provide these links only as a convenience and are not responsible for any third-party data, content, products, or services from those websites or resources or links displayed on such websites.
-- The License Database is based in part on information made available by third parties, and GitLab is not responsible for the accuracy or completeness of content made available.
+- We may change or discontinue all or any part of the Package Metadata Database, at any time and without notice, at our sole discretion.
+- The Package Metadata Database may contain links to third-party websites or resources. We provide these links only as a convenience and are not responsible for any third-party data, content, products, or services from those websites or resources or links displayed on such websites.
+- The Package Metadata Database is based in part on information made available by third parties, and GitLab is not responsible for the accuracy or completeness of content made available.
+
+Enabling the Package Metadata Database is also required to enable Continuous Vulnerability Scans for Dependency Scanning (see [epic 9534](https://gitlab.com/groups/gitlab-org/-/epics/9534) tracking this work for more info).
+
+Package metadata is stored in the following Google Cloud Provider (GCP) buckets:
+
+- License Scanning - prod-export-license-bucket-1a6c642fc4de57d4
+- Dependency Scanning - prod-export-advisory-bucket-1a6c642fc4de57d4
### Using the gsutil tool to download the package metadata exports
@@ -235,34 +242,58 @@ Note the following in relation to use of the License Database:
echo $GITLAB_RAILS_ROOT_DIR
```
+1. Set the type of data you wish to sync.
+
+ ```shell
+ # For License Scanning
+ export PKG_METADATA_BUCKET=prod-export-license-bucket-1a6c642fc4de57d4
+ export DATA_DIR="licenses"
+
+ # For Dependency Scanning
+ export PKG_METADATA_BUCKET=prod-export-advisory-bucket-1a6c642fc4de57d4
+ export DATA_DIR="advisories"
+ ```
+
1. Download the package metadata exports.
```shell
# To download the package metadata exports, an outbound connection to Google Cloud Storage bucket must be allowed.
- mkdir $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata_db/
- gsutil -m rsync -r -d gs://prod-export-license-bucket-1a6c642fc4de57d4 $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata_db/
+ mkdir -p "$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/$DATA_DIR"
+ gsutil -m rsync -r -d gs://$PKG_METADATA_BUCKET "$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/$DATA_DIR"
# Alternatively, if the GitLab instance is not allowed to connect to the Google Cloud Storage bucket, the package metadata
# exports can be downloaded using a machine with the allowed access, and then copied to the root of the GitLab Rails directory.
- rsync rsync://example_username@gitlab.example.com/package_metadata_db $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata_db/
+ rsync rsync://example_username@gitlab.example.com/package_metadata/$DATA_DIR "$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/$DATA_DIR"
```
### Using the Google Cloud Storage REST API to download the package metadata exports
-The package metadata exports can also be downloaded using the Google Cloud Storage API. The contents are available at [https://storage.googleapis.com/storage/v1/b/prod-export-license-bucket-1a6c642fc4de57d4/o](https://storage.googleapis.com/storage/v1/b/prod-export-license-bucket-1a6c642fc4de57d4/o). The following is an example of how this can be downloaded using [cURL](https://curl.se/) and [jq](https://stedolan.github.io/jq/).
+The package metadata exports can also be downloaded using the Google Cloud Storage API. The contents are available at [https://storage.googleapis.com/storage/v1/b/prod-export-license-bucket-1a6c642fc4de57d4/o](https://storage.googleapis.com/storage/v1/b/prod-export-license-bucket-1a6c642fc4de57d4/o) and [https://storage.googleapis.com/storage/v1/b/prod-export-advisory-bucket-1a6c642fc4de57d4/o](https://storage.googleapis.com/storage/v1/b/prod-export-advisory-bucket-1a6c642fc4de57d4/o). The following is an example of how this can be downloaded using [cURL](https://curl.se/) and [jq](https://stedolan.github.io/jq/).
```shell
#!/bin/bash
set -euo pipefail
+DATA_TYPE=$1
+
GITLAB_RAILS_ROOT_DIR="$(gitlab-rails runner 'puts Rails.root.to_s')"
-PKG_METADATA_DIR="$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata_db"
-PKG_METADATA_MANIFEST_OUTPUT_FILE="/tmp/license_db_export_manifest.json"
-PKG_METADATA_DOWNLOADS_OUTPUT_FILE="/tmp/license_db_object_links.tsv"
+
+if [ "$DATA_TYPE" == "license" ]; then
+ PKG_METADATA_DIR="$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses"
+elif [ "$DATA_TYPE" == "advisory" ]; then
+ PKG_METADATA_DIR="$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/advisories"
+else
+ echo "Usage: import_script.sh [licenses|advisories]"
+ exit 1
+fi
+
+PKG_METADATA_BUCKET="prod-export-$DATA_TYPE-bucket-1a6c642fc4de57d4"
+PKG_METADATA_MANIFEST_OUTPUT_FILE="/tmp/package_metadata_${DATA_TYPE}_export_manifest.json"
+PKG_METADATA_DOWNLOADS_OUTPUT_FILE="/tmp/package_metadata_${DATA_TYPE}_object_links.tsv"
# Download the contents of the bucket
-curl --silent --show-error --request GET "https://storage.googleapis.com/storage/v1/b/prod-export-license-bucket-1a6c642fc4de57d4/o?maxResults=7500" > "$PKG_METADATA_MANIFEST_OUTPUT_FILE"
+curl --silent --show-error --request GET "https://storage.googleapis.com/storage/v1/b/$PKG_METADATA_BUCKET/o?maxResults=7500" > "$PKG_METADATA_MANIFEST_OUTPUT_FILE"
# Parse the links and names for the bucket objects and output them into a tsv file
jq -r '.items[] | [.name, .mediaLink] | @tsv' "$PKG_METADATA_MANIFEST_OUTPUT_FILE" > "$PKG_METADATA_DOWNLOADS_OUTPUT_FILE"
@@ -294,9 +325,29 @@ echo "All objects saved to $PKG_METADATA_DIR"
### Automatic synchronization
-Your GitLab instance is synchronized [every hour](https://gitlab.com/gitlab-org/gitlab/-/blob/d4331343d26d6e2a81fadd8f7ecd72f7cb74d04d/config/initializers/1_settings.rb#L831-832) with the contents of the `package_metadata_db` directory.
+Your GitLab instance is synchronized [regularly](https://gitlab.com/gitlab-org/gitlab/-/blob/63a187d47f6da353ba4514650bbbbeb99c356325/config/initializers/1_settings.rb#L840-842) with the contents of the `package_metadata` directory.
To automatically update your local copy with the upstream changes, a cron job can be added to periodically download new exports. For example, the following crontabs can be added to setup a cron job that runs every 30 minutes.
+For License Scanning:
+
+```plaintext
+*/30 * * * * gsutil -m rsync -r -d gs://prod-export-license-bucket-1a6c642fc4de57d4 $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses
+```
+
+For Dependency Scanning:
+
```plaintext
-*/30 * * * * gsutil -m rsync -r -d gs://prod-export-license-bucket-1a6c642fc4de57d4 $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata_db/
+*/30 * * * * gsutil -m rsync -r -d gs://prod-export-advisory-bucket-1a6c642fc4de57d4 $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/advisories
```
+
+### Change note
+
+The directory for package metadata changed with the release of 16.2 from `vendor/package_metadata_db` to `vendor/package_metadata/licenses`. If this directory already exists on the instance and Dependency Scanning needs to be added then you need to take the following steps.
+
+1. Rename the licenses directory: `mv vendor/package_metadata_db vendor/package_metadata/licenses`.
+1. Update any automation scripts or commands saved to change `vendor/package_metadata_db` to `vendor/package_metadata/licenses`.
+1. Update any cron entries to change `vendor/package_metadata_db` to `vendor/package_metadata/licenses`.
+
+ ```shell
+ sed -i '.bckup' -e 's#vendor/package_metadata_db#vendor/package_metadata/licenses#g' [FILE ...]
+ ```
diff --git a/doc/user/analytics/img/dora_performers_score_panel_v16_3.png b/doc/user/analytics/img/dora_performers_score_panel_v16_3.png
new file mode 100644
index 00000000000..4193788aba7
--- /dev/null
+++ b/doc/user/analytics/img/dora_performers_score_panel_v16_3.png
Binary files differ
diff --git a/doc/user/analytics/value_streams_dashboard.md b/doc/user/analytics/value_streams_dashboard.md
index a853263d20f..39975476778 100644
--- a/doc/user/analytics/value_streams_dashboard.md
+++ b/doc/user/analytics/value_streams_dashboard.md
@@ -44,6 +44,18 @@ that are the largest value contributors, overperforming, or underperforming.
You can also drill down the metrics for further analysis.
When you hover over a metric, a tooltip displays an explanation of the metric and a link to the related documentation page.
+## DORA Performers score panel
+
+The DORA Performers score panel is a bar chart that visualizes the status of the organization's DevOps performance levels across different projects.
+
+The chart is a breakdown of your project's DORA scores, categorized as high, medium, or low.
+Each bar on the chart displays the sum of total projects per score category, calculated monthly.
+Hovering over each bar reveals a dialog that explains the score's definition.
+
+For example, if a project has a high score for Deployment Frequency (Velocity), it means that the project has one or more deploys to production per day.
+
+![DORA performers score](img/dora_performers_score_panel_v16_3.png)
+
## View the value streams dashboard
Prerequisite:
diff --git a/doc/user/project/repository/branches/default.md b/doc/user/project/repository/branches/default.md
index ae978e2123d..6dc12c3f4f7 100644
--- a/doc/user/project/repository/branches/default.md
+++ b/doc/user/project/repository/branches/default.md
@@ -94,7 +94,7 @@ Users with the Owner role of groups and subgroups can configure the default bran
Projects created in this group after you change the setting use the custom branch name,
unless a subgroup configuration overrides it.
-## Protect initial default branches **(FREE SELF)**
+## Protect initial default branches **(FREE)**
> Full protection after initial push [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/118729) in GitLab 16.0.
diff --git a/lib/api/projects.rb b/lib/api/projects.rb
index 468f284f136..dfd6cb2441a 100644
--- a/lib/api/projects.rb
+++ b/lib/api/projects.rb
@@ -792,10 +792,12 @@ module API
desc 'Import members from another project' do
detail 'This feature was introduced in GitLab 14.2'
- success code: 201
+ success code: 200
failure [
{ code: 403, message: 'Unauthenticated' },
- { code: 404, message: 'Not found' }
+ { code: 403, message: 'Forbidden - Project' },
+ { code: 404, message: 'Project Not Found' },
+ { code: 422, message: 'Import failed' }
]
tags %w[projects]
end
@@ -812,10 +814,12 @@ module API
result = ::Members::ImportProjectTeamService.new(current_user, params).execute
- if result
- { status: result, message: 'Successfully imported' }
+ if result.success?
+ { status: result.status }
+ elsif result.reason == :unprocessable_entity
+ render_api_error!(result.message, result.reason)
else
- render_api_error!('Import failed', :unprocessable_entity)
+ { status: result.status, message: result.message, total_members_count: result.payload[:total_members_count] }
end
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index a73a86dfaef..e7920c8e762 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -14016,6 +14016,11 @@ msgstr ""
msgid "DNS"
msgstr ""
+msgid "DORA4Metrics|%d project"
+msgid_plural "DORA4Metrics|%d projects"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "DORA4Metrics|Accept testing terms"
msgstr ""
@@ -14037,6 +14042,9 @@ msgstr ""
msgid "DORA4Metrics|Change Failure Rate"
msgstr ""
+msgid "DORA4Metrics|Change Failure Rate (Quality)"
+msgstr ""
+
msgid "DORA4Metrics|Change failure rate"
msgstr ""
@@ -14064,12 +14072,18 @@ msgstr ""
msgid "DORA4Metrics|Deployment Frequency"
msgstr ""
+msgid "DORA4Metrics|Deployment Frequency (Velocity)"
+msgstr ""
+
msgid "DORA4Metrics|Deployment frequency"
msgstr ""
msgid "DORA4Metrics|Deploys"
msgstr ""
+msgid "DORA4Metrics|Failed to load DORA performance scores for Namespace: %{fullPath}"
+msgstr ""
+
msgid "DORA4Metrics|Failed to load YAML config from Project: %{fullPath}"
msgstr ""
@@ -14091,12 +14105,32 @@ msgstr ""
msgid "DORA4Metrics|Go to docs"
msgstr ""
+msgid "DORA4Metrics|Has no calculated data."
+msgid_plural "DORA4Metrics|Have no calculated data."
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "DORA4Metrics|Have 30 or more deploys to production per day."
+msgstr ""
+
+msgid "DORA4Metrics|Have between 1 to 29 deploys to production per day."
+msgstr ""
+
+msgid "DORA4Metrics|Have less than 1 deploy to production per day."
+msgstr ""
+
+msgid "DORA4Metrics|High"
+msgstr ""
+
msgid "DORA4Metrics|High Vulnerabilities over time"
msgstr ""
msgid "DORA4Metrics|Lead Time for Changes"
msgstr ""
+msgid "DORA4Metrics|Lead Time for Changes (Velocity)"
+msgstr ""
+
msgid "DORA4Metrics|Lead time"
msgstr ""
@@ -14106,6 +14140,18 @@ msgstr ""
msgid "DORA4Metrics|Lead time for changes (median days)"
msgstr ""
+msgid "DORA4Metrics|Low"
+msgstr ""
+
+msgid "DORA4Metrics|Made 15%% or less changes to production resulted in degraded service."
+msgstr ""
+
+msgid "DORA4Metrics|Made between 16%% to 44%% of changes to production resulted in degraded service."
+msgstr ""
+
+msgid "DORA4Metrics|Made more than 45%% of changes to production resulted in degraded service."
+msgstr ""
+
msgid "DORA4Metrics|Median (last %{days}d)"
msgstr ""
@@ -14115,6 +14161,9 @@ msgstr ""
msgid "DORA4Metrics|Median time an incident was open in a production environment over the given time period."
msgstr ""
+msgid "DORA4Metrics|Medium"
+msgstr ""
+
msgid "DORA4Metrics|Merge request throughput"
msgstr ""
@@ -14130,12 +14179,18 @@ msgstr ""
msgid "DORA4Metrics|New issues"
msgstr ""
+msgid "DORA4Metrics|No data available for Namespace: %{fullPath}"
+msgstr ""
+
msgid "DORA4Metrics|No incidents during this period"
msgstr ""
msgid "DORA4Metrics|No merge requests were deployed during this period"
msgstr ""
+msgid "DORA4Metrics|Not included"
+msgstr ""
+
msgid "DORA4Metrics|Number of incidents divided by the number of deployments to a production environment in the given time period."
msgstr ""
@@ -14175,15 +14230,45 @@ msgstr ""
msgid "DORA4Metrics|This is a lower-bound approximation. Your group has too many issues and MRs to calculate in real time."
msgstr ""
+msgid "DORA4Metrics|This visualization is not supported for project namespaces."
+msgstr ""
+
msgid "DORA4Metrics|Time to Restore Service"
msgstr ""
+msgid "DORA4Metrics|Time to Restore Service (Quality)"
+msgstr ""
+
msgid "DORA4Metrics|Time to restore service"
msgstr ""
msgid "DORA4Metrics|Time to restore service (median days)"
msgstr ""
+msgid "DORA4Metrics|Took 1 day or less to restore service when a service incident or a defect that impacts users occurs."
+msgstr ""
+
+msgid "DORA4Metrics|Took 7 days or less to go from code committed to code successfully running in production."
+msgstr ""
+
+msgid "DORA4Metrics|Took between 2 to 6 days to restore service when a service incident or a defect that impacts users occurs."
+msgstr ""
+
+msgid "DORA4Metrics|Took between 8 to 29 days to go from code committed to code successfully running in production."
+msgstr ""
+
+msgid "DORA4Metrics|Took more than 30 days to go from code committed to code successfully running in production."
+msgstr ""
+
+msgid "DORA4Metrics|Took more than 7 days to restore service when a service incident or a defect that impacts users occurs."
+msgstr ""
+
+msgid "DORA4Metrics|Total projects (%{count}) by DORA performers score for %{groupName} group"
+msgstr ""
+
+msgid "DORA4Metrics|Total projects by DORA performers score"
+msgstr ""
+
msgid "DORA4Metrics|Value Streams Dashboard"
msgstr ""
@@ -25115,6 +25200,9 @@ msgstr ""
msgid "InviteMembersModal|Something went wrong"
msgstr ""
+msgid "InviteMembersModal|The following %{errorMembersLength} out of %{totalMembersCount} members could not be added"
+msgstr ""
+
msgid "InviteMembersModal|The following member couldn't be invited"
msgid_plural "InviteMembersModal|The following %d members couldn't be invited"
msgstr[0] ""
@@ -30634,6 +30722,9 @@ msgstr ""
msgid "No credit card required."
msgstr ""
+msgid "No data"
+msgstr ""
+
msgid "No data available"
msgstr ""
diff --git a/qa/qa/page/file/show.rb b/qa/qa/page/file/show.rb
index 173baa21160..284fab58d5d 100644
--- a/qa/qa/page/file/show.rb
+++ b/qa/qa/page/file/show.rb
@@ -12,7 +12,7 @@ module QA
element :lock_button
end
- view 'app/assets/javascripts/vue_shared/components/actions_button.vue' do
+ view 'app/assets/javascripts/vue_shared/components/web_ide_link.vue' do
element :action_dropdown
element :edit_menu_item, ':data-qa-selector="`${action.key}_menu_item`"' # rubocop:disable QA/ElementWithPattern
element :webide_menu_item, ':data-qa-selector="`${action.key}_menu_item`"' # rubocop:disable QA/ElementWithPattern
diff --git a/spec/features/projects/members/import_project_members_spec.rb b/spec/features/projects/members/import_project_members_spec.rb
new file mode 100644
index 00000000000..20cf42cd135
--- /dev/null
+++ b/spec/features/projects/members/import_project_members_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Projects > Members > Import project members', :js, feature_category: :groups_and_projects do
+ include Features::MembersHelpers
+ include ListboxHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:user_mike) { create(:user, name: 'Mike') }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) do
+ create(:project, group: group).tap do |p|
+ p.add_maintainer(user)
+ p.add_developer(create(:user))
+ end
+ end
+
+ let_it_be(:project2) do
+ create(:project).tap do |p|
+ p.add_maintainer(user)
+ p.add_reporter(user_mike)
+ end
+ end
+
+ before do
+ sign_in(user)
+
+ visit(project_project_members_path(project))
+ end
+
+ it 'imports a team from another project' do
+ select_project(project2)
+ submit_import
+
+ expect(find_member_row(user_mike)).to have_content('Reporter')
+ end
+
+ it 'fails to import the other team when source project does not exist' do
+ select_project(project2)
+ submit_import { project2.destroy! }
+
+ within import_project_members_modal_selector do
+ expect(page).to have_content('Unable to import project members')
+ end
+ end
+
+ it 'fails to import some members' do
+ group.add_owner(user_mike)
+
+ select_project(project2)
+ submit_import
+
+ within import_project_members_modal_selector do
+ expect(page).to have_content "The following 1 out of 2 members could not be added"
+ expect(page).to have_content "@#{user_mike.username}: Access level should be greater than or equal to " \
+ "Owner inherited membership from group #{group.name}"
+ end
+ end
+
+ def select_project(source_project)
+ click_on 'Import from a project'
+ click_on 'Select a project'
+ wait_for_requests
+
+ select_listbox_item(source_project.name_with_namespace)
+ end
+
+ def submit_import
+ yield if block_given? # rubocop:disable RSpec/AvoidConditionalStatements
+
+ click_button 'Import project members'
+ wait_for_requests
+ end
+
+ def import_project_members_modal_selector
+ '[data-testid="import-project-members-modal"]'
+ end
+end
diff --git a/spec/features/projects/settings/user_manages_project_members_spec.rb b/spec/features/projects/members/user_manages_project_members_spec.rb
index df571e13979..b1c3132767c 100644
--- a/spec/features/projects/settings/user_manages_project_members_spec.rb
+++ b/spec/features/projects/members/user_manages_project_members_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Projects > Settings > User manages project members', feature_category: :groups_and_projects do
+RSpec.describe 'Projects > Settings > User manages project members', :js, feature_category: :groups_and_projects do
include Features::MembersHelpers
include Spec::Support::Helpers::ModalHelpers
include ListboxHelpers
@@ -20,7 +20,7 @@ RSpec.describe 'Projects > Settings > User manages project members', feature_cat
sign_in(user)
end
- it 'cancels a team member', :js do
+ it 'cancels a team member' do
visit(project_project_members_path(project))
show_actions_for_username(user_dmitriy)
@@ -37,24 +37,7 @@ RSpec.describe 'Projects > Settings > User manages project members', feature_cat
expect(members_table).not_to have_content(user_dmitriy.username)
end
- it 'imports a team from another project', :js do
- project2.add_maintainer(user)
- project2.add_reporter(user_mike)
-
- visit(project_project_members_path(project))
-
- click_on 'Import from a project'
- click_on 'Select a project'
- wait_for_requests
-
- select_listbox_item(project2.name_with_namespace)
- click_button 'Import project members'
- wait_for_requests
-
- expect(find_member_row(user_mike)).to have_content('Reporter')
- end
-
- it 'shows all members of project shared group', :js do
+ it 'shows all members of project shared group' do
group.add_owner(user)
group.add_developer(user_dmitriy)
diff --git a/spec/frontend/invite_members/components/import_project_members_modal_spec.js b/spec/frontend/invite_members/components/import_project_members_modal_spec.js
index 224ebe18e2a..4fac21f7a57 100644
--- a/spec/frontend/invite_members/components/import_project_members_modal_spec.js
+++ b/spec/frontend/invite_members/components/import_project_members_modal_spec.js
@@ -1,4 +1,4 @@
-import { GlFormGroup, GlSprintf, GlModal } from '@gitlab/ui';
+import { GlFormGroup, GlSprintf, GlModal, GlCollapse, GlIcon } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import { createWrapper } from '@vue/test-utils';
@@ -12,6 +12,7 @@ import eventHub from '~/invite_members/event_hub';
import ImportProjectMembersModal from '~/invite_members/components/import_project_members_modal.vue';
import ProjectSelect from '~/invite_members/components/project_select.vue';
import axios from '~/lib/utils/axios_utils';
+import { HTTP_STATUS_CREATED } from '~/lib/utils/http_status';
import {
displaySuccessfulInvitationAlert,
@@ -19,9 +20,14 @@ import {
} from '~/invite_members/utils/trigger_successful_invite_alert';
import {
+ EXPANDED_ERRORS,
IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_CATEGORY,
IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_LABEL,
} from '~/invite_members/constants';
+import {
+ IMPORT_PROJECT_MEMBERS_PATH,
+ importProjectMembersApiResponse,
+} from '../mock_data/api_responses';
jest.mock('~/invite_members/utils/trigger_successful_invite_alert');
@@ -92,6 +98,14 @@ describe('ImportProjectMembersModal', () => {
const formGroupInvalidFeedback = () => findFormGroup().props('invalidFeedback');
const formGroupErrorState = () => findFormGroup().props('state');
const findProjectSelect = () => wrapper.findComponent(ProjectSelect);
+ const findMemberErrorAlert = () => wrapper.findByTestId('alert-member-error');
+ const findMoreInviteErrorsButton = () => wrapper.findByTestId('accordion-button');
+ const findAccordion = () => wrapper.findComponent(GlCollapse);
+ const findErrorsIcon = () => wrapper.findComponent(GlIcon);
+ const findMemberErrorMessage = (element) =>
+ `@${Object.keys(importProjectMembersApiResponse.EXPANDED_IMPORT_ERRORS.message)[element]}: ${
+ Object.values(importProjectMembersApiResponse.EXPANDED_IMPORT_ERRORS.message)[element]
+ }`;
describe('rendering the modal', () => {
beforeEach(() => {
@@ -241,7 +255,7 @@ describe('ImportProjectMembersModal', () => {
});
});
- describe('when the import fails', () => {
+ describe('when the import fails due to generic api error', () => {
beforeEach(async () => {
createComponent();
@@ -276,5 +290,82 @@ describe('ImportProjectMembersModal', () => {
expect(formGroupErrorState()).not.toBe(false);
});
});
+
+ describe('when the import fails with member import errors', () => {
+ const mockInvitationsApi = (code, data) => {
+ mock.onPost(IMPORT_PROJECT_MEMBERS_PATH).reply(code, data);
+ };
+
+ beforeEach(() => {
+ createComponent();
+ findProjectSelect().vm.$emit('input', projectToBeImported);
+ });
+
+ it('displays the error alert', async () => {
+ mockInvitationsApi(
+ HTTP_STATUS_CREATED,
+ importProjectMembersApiResponse.NO_COLLAPSE_IMPORT_ERRORS,
+ );
+
+ clickImportButton();
+ await waitForPromises();
+
+ expect(findMemberErrorAlert().props('title')).toContain(
+ 'The following 2 out of 2 members could not be added',
+ );
+ expect(findMemberErrorAlert().text()).toContain(findMemberErrorMessage(0));
+ expect(findMemberErrorAlert().text()).toContain(findMemberErrorMessage(1));
+ });
+
+ it('displays collapse when there are more than 2 errors', async () => {
+ mockInvitationsApi(
+ HTTP_STATUS_CREATED,
+ importProjectMembersApiResponse.EXPANDED_IMPORT_ERRORS,
+ );
+
+ clickImportButton();
+ await waitForPromises();
+
+ expect(findAccordion().exists()).toBe(true);
+ expect(findMoreInviteErrorsButton().text()).toContain('Show more (2)');
+ });
+
+ it('toggles the collapse on click', async () => {
+ mockInvitationsApi(
+ HTTP_STATUS_CREATED,
+ importProjectMembersApiResponse.EXPANDED_IMPORT_ERRORS,
+ );
+
+ clickImportButton();
+ await waitForPromises();
+
+ expect(findMoreInviteErrorsButton().text()).toContain('Show more (2)');
+ expect(findErrorsIcon().attributes('class')).not.toContain('gl-rotate-180');
+ expect(findAccordion().attributes('visible')).toBeUndefined();
+
+ await findMoreInviteErrorsButton().vm.$emit('click');
+
+ expect(findMoreInviteErrorsButton().text()).toContain(EXPANDED_ERRORS);
+ expect(findErrorsIcon().attributes('class')).toContain('gl-rotate-180');
+ expect(findAccordion().attributes('visible')).toBeDefined();
+
+ await findMoreInviteErrorsButton().vm.$emit('click');
+
+ expect(findMoreInviteErrorsButton().text()).toContain('Show more (2)');
+ });
+
+ it("doesn't display collapse when there are 2 or less errors", async () => {
+ mockInvitationsApi(
+ HTTP_STATUS_CREATED,
+ importProjectMembersApiResponse.NO_COLLAPSE_IMPORT_ERRORS,
+ );
+
+ clickImportButton();
+ await waitForPromises();
+
+ expect(findAccordion().exists()).toBe(false);
+ expect(findMoreInviteErrorsButton().exists()).toBe(false);
+ });
+ });
});
});
diff --git a/spec/frontend/invite_members/mock_data/api_responses.js b/spec/frontend/invite_members/mock_data/api_responses.js
index 6fe06decb6b..e3e2426fcfc 100644
--- a/spec/frontend/invite_members/mock_data/api_responses.js
+++ b/spec/frontend/invite_members/mock_data/api_responses.js
@@ -57,3 +57,27 @@ export const invitationsApiResponse = {
EMAIL_TAKEN,
EXPANDED_RESTRICTED,
};
+
+export const IMPORT_PROJECT_MEMBERS_PATH = '/api/v4/projects/1/import_project_members/2';
+const EXPANDED_IMPORT_ERRORS = {
+ message: {
+ bob_smith: 'Something is wrong for this member.',
+ john_smith: 'Something is wrong for this member.',
+ doug_logan: 'Something is wrong for this member.',
+ root: 'Something is wrong for this member.',
+ },
+ total_members_count: '4',
+ status: 'error',
+};
+const NO_COLLAPSE_IMPORT_ERRORS = {
+ message: {
+ bob_smith: 'Something is wrong for this member.',
+ john_smith: 'Something is wrong for this member.',
+ },
+ total_members_count: '2',
+ status: 'error',
+};
+export const importProjectMembersApiResponse = {
+ EXPANDED_IMPORT_ERRORS,
+ NO_COLLAPSE_IMPORT_ERRORS,
+};
diff --git a/spec/frontend/vue_shared/components/actions_button_spec.js b/spec/frontend/vue_shared/components/actions_button_spec.js
deleted file mode 100644
index 9f9a27c6997..00000000000
--- a/spec/frontend/vue_shared/components/actions_button_spec.js
+++ /dev/null
@@ -1,119 +0,0 @@
-import {
- GlDisclosureDropdown,
- GlDisclosureDropdownGroup,
- GlDisclosureDropdownItem,
-} from '@gitlab/ui';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import ActionsButton from '~/vue_shared/components/actions_button.vue';
-
-const TEST_ACTION = {
- key: 'action1',
- text: 'Sample',
- secondaryText: 'Lorem ipsum.',
- href: '/sample',
- attrs: {
- 'data-test': '123',
- category: 'secondary',
- href: '/sample',
- variant: 'default',
- },
- handle: jest.fn(),
-};
-const TEST_ACTION_2 = {
- key: 'action2',
- text: 'Sample 2',
- secondaryText: 'Dolar sit amit.',
- href: '#',
- attrs: { 'data-test': '456' },
- handle: jest.fn(),
-};
-
-describe('vue_shared/components/actions_button', () => {
- let wrapper;
-
- function createComponent({ props = {}, slots = {} } = {}) {
- wrapper = shallowMountExtended(ActionsButton, {
- propsData: { actions: [TEST_ACTION, TEST_ACTION_2], toggleText: 'Edit', ...props },
- stubs: {
- GlDisclosureDropdownItem,
- },
- slots,
- });
- }
- const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
-
- it('dropdown toggle displays provided toggleLabel', () => {
- createComponent();
-
- expect(findDropdown().props().toggleText).toBe('Edit');
- });
-
- it('dropdown has a fluid width', () => {
- createComponent();
-
- expect(findDropdown().props().fluidWidth).toBe(true);
- });
-
- it('provides a default slot', () => {
- const slotContent = 'default text';
-
- createComponent({
- slots: {
- default: slotContent,
- },
- });
-
- expect(findDropdown().text()).toContain(slotContent);
- });
-
- it('allows customizing variant and category', () => {
- const variant = 'confirm';
- const category = 'secondary';
-
- createComponent({ props: { variant, category } });
-
- expect(findDropdown().props()).toMatchObject({ category, variant });
- });
-
- it('displays a single dropdown group', () => {
- createComponent();
-
- expect(wrapper.findAllComponents(GlDisclosureDropdownGroup)).toHaveLength(1);
- });
-
- it('create dropdown items for every action', () => {
- createComponent();
-
- [TEST_ACTION, TEST_ACTION_2].forEach((action, index) => {
- const dropdownItem = wrapper.findAllComponents(GlDisclosureDropdownItem).at(index);
-
- expect(dropdownItem.props().item).toBe(action);
- expect(dropdownItem.attributes()).toMatchObject(action.attrs);
- expect(dropdownItem.text()).toContain(action.text);
- expect(dropdownItem.text()).toContain(action.secondaryText);
- });
- });
-
- describe('when clicking a dropdown item', () => {
- it("invokes the action's handle method", () => {
- createComponent();
-
- [TEST_ACTION, TEST_ACTION_2].forEach((action, index) => {
- const dropdownItem = wrapper.findAllComponents(GlDisclosureDropdownItem).at(index);
-
- dropdownItem.vm.$emit('action');
-
- expect(action.handle).toHaveBeenCalled();
- });
- });
- });
-
- it.each(['shown', 'hidden'])(
- 'bubbles up %s event from the disclosure dropdown component',
- (event) => {
- createComponent();
- findDropdown().vm.$emit(event);
- expect(wrapper.emitted(event)).toHaveLength(1);
- },
- );
-});
diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js
index b6c22ceaa23..063f1d793ef 100644
--- a/spec/frontend/vue_shared/components/web_ide_link_spec.js
+++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js
@@ -1,14 +1,17 @@
-import { GlModal } from '@gitlab/ui';
+import { GlModal, GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import getWritableForksResponse from 'test_fixtures/graphql/vue_shared/components/web_ide/get_writable_forks.query.graphql_none.json';
-import ActionsButton from '~/vue_shared/components/actions_button.vue';
import WebIdeLink, { i18n } from '~/vue_shared/components/web_ide_link.vue';
import ConfirmForkModal from '~/vue_shared/components/web_ide/confirm_fork_modal.vue';
import { stubComponent } from 'helpers/stub_component';
-import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
+import {
+ shallowMountExtended,
+ mountExtended,
+ extendedWrapper,
+} from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import { visitUrl } from '~/lib/utils/url_utility';
@@ -26,11 +29,11 @@ const forkPath = '/some/fork/path';
const ACTION_EDIT = {
href: TEST_EDIT_URL,
- key: 'edit',
+ handle: undefined,
text: 'Edit single file',
secondaryText: 'Edit this file only.',
attrs: {
- 'data-qa-selector': 'edit_button',
+ 'data-qa-selector': 'edit_menu_item',
'data-track-action': 'click_consolidated_edit',
'data-track-label': 'edit',
},
@@ -41,14 +44,14 @@ const ACTION_EDIT_CONFIRM_FORK = {
handle: expect.any(Function),
};
const ACTION_WEB_IDE = {
- key: 'webide',
secondaryText: i18n.webIdeText,
text: 'Web IDE',
attrs: {
- 'data-qa-selector': 'web_ide_button',
+ 'data-qa-selector': 'webide_menu_item',
'data-track-action': 'click_consolidated_edit_ide',
'data-track-label': 'web_ide',
},
+ href: undefined,
handle: expect.any(Function),
};
const ACTION_WEB_IDE_CONFIRM_FORK = {
@@ -58,11 +61,11 @@ const ACTION_WEB_IDE_CONFIRM_FORK = {
const ACTION_WEB_IDE_EDIT_FORK = { ...ACTION_WEB_IDE, text: 'Edit fork in Web IDE' };
const ACTION_GITPOD = {
href: TEST_GITPOD_URL,
- key: 'gitpod',
+ handle: undefined,
secondaryText: 'Launch a ready-to-code development environment for your project.',
text: 'Gitpod',
attrs: {
- 'data-qa-selector': 'gitpod_button',
+ 'data-qa-selector': 'gitpod_menu_item',
},
};
const ACTION_GITPOD_ENABLE = {
@@ -72,11 +75,12 @@ const ACTION_GITPOD_ENABLE = {
};
const ACTION_PIPELINE_EDITOR = {
href: TEST_PIPELINE_EDITOR_URL,
- key: 'pipeline_editor',
secondaryText: 'Edit, lint, and visualize your pipeline.',
text: 'Edit in pipeline editor',
attrs: {
- 'data-qa-selector': 'pipeline_editor_button',
+ 'data-qa-selector': 'pipeline_editor_menu_item',
+ 'data-track-action': 'click_consolidated_pipeline_editor',
+ 'data-track-label': 'pipeline_editor',
},
};
@@ -108,14 +112,34 @@ describe('vue_shared/components/web_ide_link', () => {
<slot name="modal-footer"></slot>
</div>`,
}),
+ GlDisclosureDropdownItem,
},
apolloProvider: fakeApollo,
});
}
- const findActionsButton = () => wrapper.findComponent(ActionsButton);
+ const findDisclosureDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
+ const findDisclosureDropdownItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem);
const findModal = () => wrapper.findComponent(GlModal);
const findForkConfirmModal = () => wrapper.findComponent(ConfirmForkModal);
+ const getDropdownItemsAsData = () =>
+ findDisclosureDropdownItems().wrappers.map((item) => {
+ const extendedWrapperItem = extendedWrapper(item);
+ const attributes = extendedWrapperItem.attributes();
+ const props = extendedWrapperItem.props();
+
+ return {
+ text: extendedWrapperItem.findByTestId('action-primary-text').text(),
+ secondaryText: extendedWrapperItem.findByTestId('action-secondary-text').text(),
+ href: props.item.href,
+ handle: props.item.handle,
+ attrs: {
+ 'data-qa-selector': attributes['data-qa-selector'],
+ 'data-track-action': attributes['data-track-action'],
+ 'data-track-label': attributes['data-track-label'],
+ },
+ };
+ });
it.each([
{
@@ -210,7 +234,7 @@ describe('vue_shared/components/web_ide_link', () => {
])('renders actions with appropriately for given props', ({ props, expectedActions }) => {
createComponent(props);
- expect(findActionsButton().props('actions')).toEqual(expectedActions);
+ expect(getDropdownItemsAsData()).toEqual(expectedActions);
});
it('bubbles up shown and hidden events triggered by actions button component', () => {
@@ -219,17 +243,17 @@ describe('vue_shared/components/web_ide_link', () => {
expect(wrapper.emitted('shown')).toBe(undefined);
expect(wrapper.emitted('hidden')).toBe(undefined);
- findActionsButton().vm.$emit('shown');
- findActionsButton().vm.$emit('hidden');
+ findDisclosureDropdown().vm.$emit('shown');
+ findDisclosureDropdown().vm.$emit('hidden');
expect(wrapper.emitted('shown')).toHaveLength(1);
expect(wrapper.emitted('hidden')).toHaveLength(1);
});
- it('exposes a default slot', () => {
- const slotContent = 'default slot content';
+ it.each(['before-actions', 'after-actions'])('exposes a %s slot', (slot) => {
+ const slotContent = 'slot content';
- createComponent({}, { slots: { default: slotContent } });
+ createComponent({}, { slots: { [slot]: slotContent } });
expect(wrapper.text()).toContain(slotContent);
});
@@ -248,13 +272,15 @@ describe('vue_shared/components/web_ide_link', () => {
});
it('displays Pipeline Editor as the first action', () => {
- expect(findActionsButton().props()).toMatchObject({
- actions: [ACTION_PIPELINE_EDITOR, ACTION_WEB_IDE, ACTION_GITPOD],
- });
+ expect(getDropdownItemsAsData()).toEqual([
+ ACTION_PIPELINE_EDITOR,
+ ACTION_WEB_IDE,
+ ACTION_GITPOD,
+ ]);
});
it('when web ide button is clicked it opens in a new tab', async () => {
- findActionsButton().props('actions')[1].handle();
+ findDisclosureDropdownItems().at(1).props().item.handle();
await nextTick();
expect(visitUrl).toHaveBeenCalledWith(TEST_WEB_IDE_URL, true);
});
@@ -289,7 +315,7 @@ describe('vue_shared/components/web_ide_link', () => {
({ props, expectedEventPayload }) => {
createComponent({ ...props, needsToFork: true, disableForkModal: true });
- findActionsButton().props('actions')[0].handle();
+ findDisclosureDropdownItems().at(0).props().item.handle();
expect(wrapper.emitted('edit')).toEqual([[expectedEventPayload]]);
},
@@ -309,7 +335,7 @@ describe('vue_shared/components/web_ide_link', () => {
it.each(testActions)('opens the modal when the button is clicked', async ({ props }) => {
createComponent({ ...props, needsToFork: true }, { mountFn: mountExtended });
- wrapper.findComponent(ActionsButton).props().actions[0].handle();
+ findDisclosureDropdownItems().at(0).props().item.handle();
await nextTick();
await wrapper.findByRole('button', { name: /Web IDE|Edit/im }).trigger('click');
diff --git a/spec/models/project_team_spec.rb b/spec/models/project_team_spec.rb
index f3139e72113..7df49fbe548 100644
--- a/spec/models/project_team_spec.rb
+++ b/spec/models/project_team_spec.rb
@@ -176,10 +176,11 @@ RSpec.describe ProjectTeam, feature_category: :groups_and_projects do
let_it_be(:source_project_owner) { source_project.first_owner }
let_it_be(:source_project_developer) { create(:user) { |user| source_project.add_developer(user) } }
let_it_be(:current_user) { create(:user) { |user| target_project.add_maintainer(user) } }
+ let(:imported_members) { [source_project_owner.members.last, source_project_developer.members.last] }
subject(:import) { target_project.team.import(source_project, current_user) }
- it { is_expected.to be_truthy }
+ it { is_expected.to match(imported_members) }
it 'target project includes source member with the same access' do
import
@@ -219,6 +220,12 @@ RSpec.describe ProjectTeam, feature_category: :groups_and_projects do
let(:target_access_level) { Gitlab::Access::OWNER }
end
end
+
+ context 'when source_project does not exist' do
+ let_it_be(:source_project) { nil }
+
+ it { is_expected.to eq(false) }
+ end
end
describe '#find_member' do
diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb
index f5d1bbbc7e8..1ff2adfcbcf 100644
--- a/spec/requests/api/projects_spec.rb
+++ b/spec/requests/api/projects_spec.rb
@@ -3654,7 +3654,7 @@ RSpec.describe API::Projects, :aggregate_failures, feature_category: :groups_and
end.to change { project.members.count }.by(2)
expect(response).to have_gitlab_http_status(:created)
- expect(json_response['message']).to eq('Successfully imported')
+ expect(json_response['status']).to eq('success')
end
it 'returns 404 if the source project does not exist' do
@@ -3712,6 +3712,22 @@ RSpec.describe API::Projects, :aggregate_failures, feature_category: :groups_and
expect(response).to have_gitlab_http_status(:unprocessable_entity)
expect(json_response['message']).to eq('Import failed')
end
+
+ context 'when importing of members did not work for some or all members' do
+ it 'fails to import some members' do
+ project_bot = create(:user, :project_bot)
+ project2.add_developer(project_bot)
+
+ expect do
+ post api(path, user)
+ end.to change { project.members.count }.by(2)
+
+ expect(response).to have_gitlab_http_status(:created)
+ error_message = { project_bot.username => 'User project bots cannot be added to other groups / projects' }
+ expect(json_response['message']).to eq(error_message)
+ expect(json_response['total_members_count']).to eq(3)
+ end
+ end
end
describe 'PUT /projects/:id' do
diff --git a/spec/services/members/import_project_team_service_spec.rb b/spec/services/members/import_project_team_service_spec.rb
index 7dcdb70f2cd..e0657cfa8cc 100644
--- a/spec/services/members/import_project_team_service_spec.rb
+++ b/spec/services/members/import_project_team_service_spec.rb
@@ -8,7 +8,10 @@ RSpec.describe Members::ImportProjectTeamService, feature_category: :groups_and_
let_it_be(:target_project) { create(:project) }
let_it_be(:user) { create(:user) }
- subject { described_class.new(user, { id: target_project_id, project_id: source_project_id }) }
+ let(:source_project_id) { source_project.id }
+ let(:target_project_id) { target_project.id }
+
+ subject(:import) { described_class.new(user, { id: target_project_id, project_id: source_project_id }) }
before_all do
source_project.add_guest(user)
@@ -16,74 +19,110 @@ RSpec.describe Members::ImportProjectTeamService, feature_category: :groups_and_
end
context 'when project team members are imported successfully' do
- let(:source_project_id) { source_project.id }
- let(:target_project_id) { target_project.id }
+ it 'returns a successful response' do
+ result = import.execute
- it 'returns true' do
- expect(subject.execute).to be(true)
+ expect(result).to be_a(ServiceResponse)
+ expect(result.success?).to be(true)
+ expect(result.message).to eq('Successfully imported')
end
end
context 'when the project team import fails' do
context 'when the target project cannot be found' do
- let(:source_project_id) { source_project.id }
let(:target_project_id) { non_existing_record_id }
- it 'returns false' do
- expect(subject.execute).to be(false)
+ it 'returns unsuccessful response' do
+ result = import.execute
+
+ expect(result).to be_a(ServiceResponse)
+ expect(result.error?).to be(true)
+ expect(result.message).to eq('Target project does not exist')
+ expect(result.reason).to eq(:unprocessable_entity)
end
end
context 'when the source project cannot be found' do
let(:source_project_id) { non_existing_record_id }
- let(:target_project_id) { target_project.id }
- it 'returns false' do
- expect(subject.execute).to be(false)
+ it 'returns unsuccessful response' do
+ result = import.execute
+
+ expect(result).to be_a(ServiceResponse)
+ expect(result.error?).to be(true)
+ expect(result.message).to eq('Source project does not exist')
+ expect(result.reason).to eq(:unprocessable_entity)
end
end
context 'when the user doing the import does not exist' do
let(:user) { nil }
- let(:source_project_id) { source_project.id }
- let(:target_project_id) { target_project.id }
- it 'returns false' do
- expect(subject.execute).to be(false)
+ it 'returns unsuccessful response' do
+ result = import.execute
+
+ expect(result).to be_a(ServiceResponse)
+ expect(result.error?).to be(true)
+ expect(result.message).to eq('Forbidden')
+ expect(result.reason).to eq(:unprocessable_entity)
end
end
context 'when the user does not have permission to read the source project members' do
- let(:user) { create(:user) }
let(:source_project_id) { create(:project, :private).id }
- let(:target_project_id) { target_project.id }
- it 'returns false' do
- expect(subject.execute).to be(false)
+ it 'returns unsuccessful response' do
+ result = import.execute
+
+ expect(result).to be_a(ServiceResponse)
+ expect(result.error?).to be(true)
+ expect(result.message).to eq('Forbidden')
+ expect(result.reason).to eq(:unprocessable_entity)
end
end
context 'when the user does not have permission to admin the target project' do
- let(:source_project_id) { source_project.id }
let(:target_project_id) { create(:project).id }
- it 'returns false' do
- expect(subject.execute).to be(false)
+ it 'returns unsuccessful response' do
+ result = import.execute
+
+ expect(result).to be_a(ServiceResponse)
+ expect(result.error?).to be(true)
+ expect(result.message).to eq('Forbidden')
+ expect(result.reason).to eq(:unprocessable_entity)
end
end
context 'when the source and target project are valid but the ProjectTeam#import command fails' do
- let(:source_project_id) { source_project.id }
- let(:target_project_id) { target_project.id }
-
before do
allow_next_instance_of(ProjectTeam) do |project_team|
allow(project_team).to receive(:import).and_return(false)
end
end
- it 'returns false' do
- expect(subject.execute).to be(false)
+ it 'returns unsuccessful response' do
+ result = import.execute
+
+ expect(result).to be_a(ServiceResponse)
+ expect(result.error?).to be(true)
+ expect(result.message).to eq('Import failed')
+ expect(result.reason).to eq(:unprocessable_entity)
+ end
+ end
+
+ context 'when one of the imported project members is invalid' do
+ it 'returns unsuccessful response' do
+ project_bot = create(:user, :project_bot)
+ source_project.add_developer(project_bot)
+
+ result = import.execute
+
+ expect(result).to be_a(ServiceResponse)
+ expect(result.error?).to be(true)
+ message = { project_bot.username => 'User project bots cannot be added to other groups / projects' }
+ expect(result.message).to eq(message)
+ expect(result.payload[:total_members_count]).to eq(2)
end
end
end
diff --git a/spec/support/rspec_order_todo.yml b/spec/support/rspec_order_todo.yml
index 3cce22c00e6..b9ddd4a7385 100644
--- a/spec/support/rspec_order_todo.yml
+++ b/spec/support/rspec_order_todo.yml
@@ -4149,7 +4149,7 @@
- './spec/features/projects/settings/user_changes_default_branch_spec.rb'
- './spec/features/projects/settings/user_interacts_with_deploy_keys_spec.rb'
- './spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb'
-- './spec/features/projects/settings/user_manages_project_members_spec.rb'
+- './spec/features/projects/members/user_manages_project_members_spec.rb'
- './spec/features/projects/settings/user_renames_a_project_spec.rb'
- './spec/features/projects/settings/user_searches_in_settings_spec.rb'
- './spec/features/projects/settings/user_sees_revoke_deploy_token_modal_spec.rb'