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>2022-11-22 06:10:55 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-11-22 06:10:55 +0300
commite0b6b475f203ebbe63a903328369c1363a747498 (patch)
treedb5dd8ef36a7a5c81c39d94a53dd4d3ad39151fc
parent981548e28502956e47ac43c978cc36908636c265 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/groups/components/group_name_and_path.vue2
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue1
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue46
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue46
-rw-r--r--app/assets/javascripts/work_items/constants.js12
-rw-r--r--app/assets/javascripts/work_items/index.js1
-rw-r--r--app/controllers/import/bitbucket_controller.rb23
-rw-r--r--app/services/bulk_imports/create_service.rb30
-rw-r--r--app/services/groups/import_export/import_service.rb18
-rw-r--r--app/services/import/base_service.rb25
-rw-r--r--app/services/import/bitbucket_server_service.rb2
-rw-r--r--app/services/import/github_service.rb1
-rw-r--r--app/views/admin/application_settings/_kroki.html.haml2
-rw-r--r--config/feature_flags/development/graphql_keyset_pagination_without_next_page_query.yml8
-rw-r--r--doc/user/project/merge_requests/reviews/index.md44
-rw-r--r--lib/gitlab/graphql/pagination/keyset/connection.rb21
-rw-r--r--locale/gitlab.pot23
-rw-r--r--qa/README.md5
-rw-r--r--spec/controllers/import/bitbucket_controller_spec.rb24
-rw-r--r--spec/frontend/groups/components/group_name_and_path_spec.js2
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js31
-rw-r--r--spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb383
-rw-r--r--spec/services/bulk_imports/create_service_spec.rb116
-rw-r--r--spec/services/groups/import_export/import_service_spec.rb42
-rw-r--r--spec/services/import/bitbucket_server_service_spec.rb19
-rw-r--r--spec/services/import/github_service_spec.rb23
26 files changed, 511 insertions, 439 deletions
diff --git a/app/assets/javascripts/groups/components/group_name_and_path.vue b/app/assets/javascripts/groups/components/group_name_and_path.vue
index 9a1ea2f1812..5f997ecc7ba 100644
--- a/app/assets/javascripts/groups/components/group_name_and_path.vue
+++ b/app/assets/javascripts/groups/components/group_name_and_path.vue
@@ -59,7 +59,7 @@ export default {
learnMore: s__('Groups|Learn more'),
},
inputSize: { md: 'lg' },
- changingGroupPathHelpPagePath: helpPagePath('user/group/index', {
+ changingGroupPathHelpPagePath: helpPagePath('user/group/manage', {
anchor: 'change-a-groups-path',
}),
mattermostDataBindName: 'create_chat_team',
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 8bc78170872..5316fb9b84f 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -519,6 +519,7 @@ export default {
<work-item-tree
v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE"
:work-item-type="workItemType"
+ :work-item-id="workItem.id"
/>
<gl-empty-state
v-if="error"
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
index 095ea86e0d8..22bbfd36a20 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
@@ -9,7 +9,16 @@ import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_ty
import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql';
import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
import createWorkItemMutation from '../../graphql/create_work_item.mutation.graphql';
-import { FORM_TYPES, TASK_TYPE_NAME } from '../../constants';
+import {
+ FORM_TYPES,
+ WORK_ITEMS_TYPE_MAP,
+ WORK_ITEM_TYPE_ENUM_TASK,
+ I18N_WORK_ITEM_CREATE_BUTTON_LABEL,
+ I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER,
+ I18N_WORK_ITEM_ADD_BUTTON_LABEL,
+ I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL,
+ sprintfWorkItem,
+} from '../../constants';
export default {
components: {
@@ -52,6 +61,11 @@ export default {
type: String,
required: true,
},
+ childrenType: {
+ type: String,
+ required: false,
+ default: WORK_ITEM_TYPE_ENUM_TASK,
+ },
},
apollo: {
workItemTypes: {
@@ -71,7 +85,7 @@ export default {
return {
projectPath: this.projectPath,
searchTerm: this.search?.title || this.search,
- types: ['TASK'],
+ types: [this.childrenType],
in: this.search ? 'TITLE' : undefined,
};
},
@@ -79,7 +93,9 @@ export default {
return !this.searchStarted;
},
update(data) {
- return data.workspace.workItems.nodes.filter((wi) => !this.childrenIds.includes(wi.id));
+ return data.workspace.workItems.nodes.filter(
+ (wi) => !this.childrenIds.includes(wi.id) && this.issuableGid !== wi.id,
+ );
},
},
},
@@ -99,7 +115,7 @@ export default {
let workItemInput = {
title: this.search?.title || this.search,
projectPath: this.projectPath,
- workItemTypeId: this.taskWorkItemType,
+ workItemTypeId: this.childWorkItemType,
hierarchyWidget: {
parentId: this.issuableGid,
},
@@ -122,19 +138,22 @@ export default {
isCreateForm() {
return this.formType === FORM_TYPES.create;
},
+ childrenTypeName() {
+ return WORK_ITEMS_TYPE_MAP[this.childrenType]?.name;
+ },
addOrCreateButtonLabel() {
if (this.isCreateForm) {
- return this.$options.i18n.createChildOptionLabel;
+ return sprintfWorkItem(I18N_WORK_ITEM_CREATE_BUTTON_LABEL, this.childrenTypeName);
} else if (this.workItemsToAdd.length > 1) {
- return this.$options.i18n.addTasksButtonLabel;
+ return sprintfWorkItem(I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL, this.childrenTypeName);
}
- return this.$options.i18n.addTaskButtonLabel;
+ return sprintfWorkItem(I18N_WORK_ITEM_ADD_BUTTON_LABEL, this.childrenTypeName);
},
addOrCreateMethod() {
return this.isCreateForm ? this.createChild : this.addChild;
},
- taskWorkItemType() {
- return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id;
+ childWorkItemType() {
+ return this.workItemTypes.find((type) => type.name === this.childrenTypeName)?.id;
},
parentIterationId() {
return this.parentIteration?.id;
@@ -154,6 +173,9 @@ export default {
isLoading() {
return this.$apollo.queries.availableWorkItems.loading;
},
+ addInputPlaceholder() {
+ return sprintfWorkItem(I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, this.childrenTypeName);
+ },
},
created() {
this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
@@ -253,17 +275,13 @@ export default {
},
i18n: {
inputLabel: __('Title'),
- addTaskButtonLabel: s__('WorkItem|Add task'),
- addTasksButtonLabel: s__('WorkItem|Add tasks'),
addChildErrorMessage: s__(
'WorkItem|Something went wrong when trying to add a child. Please try again.',
),
- createChildOptionLabel: s__('WorkItem|Create task'),
createChildErrorMessage: s__(
'WorkItem|Something went wrong when trying to create a child. Please try again.',
),
createPlaceholder: s__('WorkItem|Add a title'),
- addPlaceholder: s__('WorkItem|Search existing tasks'),
fieldValidationMessage: __('Maximum of 255 characters'),
},
};
@@ -296,7 +314,7 @@ export default {
v-model="workItemsToAdd"
:dropdown-items="availableWorkItems"
:loading="isLoading"
- :placeholder="$options.i18n.addPlaceholder"
+ :placeholder="addInputPlaceholder"
menu-class="gl-dropdown-menu-wide dropdown-reduced-height gl-min-h-7!"
class="gl-mb-4"
data-testid="work-item-token-select-input"
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
index 49430cc4064..9c09ee3a66a 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
@@ -2,26 +2,42 @@
import { GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
-import { WORK_ITEMS_TREE_TEXT_MAP } from '../../constants';
+import {
+ FORM_TYPES,
+ WORK_ITEMS_TREE_TEXT_MAP,
+ WORK_ITEM_TYPE_ENUM_OBJECTIVE,
+ WORK_ITEM_TYPE_ENUM_KEY_RESULT,
+} from '../../constants';
import OkrActionsSplitButton from './okr_actions_split_button.vue';
+import WorkItemLinksForm from './work_item_links_form.vue';
export default {
+ FORM_TYPES,
WORK_ITEMS_TREE_TEXT_MAP,
+ WORK_ITEM_TYPE_ENUM_OBJECTIVE,
+ WORK_ITEM_TYPE_ENUM_KEY_RESULT,
components: {
GlButton,
OkrActionsSplitButton,
+ WorkItemLinksForm,
},
props: {
workItemType: {
type: String,
required: true,
},
+ workItemId: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
isShownAddForm: false,
isOpen: true,
error: null,
+ formType: null,
+ childType: null,
};
},
computed: {
@@ -36,9 +52,11 @@ export default {
toggle() {
this.isOpen = !this.isOpen;
},
- showAddForm() {
+ showAddForm(formType, childType) {
this.isOpen = true;
this.isShownAddForm = true;
+ this.formType = formType;
+ this.childType = childType;
this.$nextTick(() => {
this.$refs.wiLinksForm.$refs.wiTitleInput?.$el.focus();
});
@@ -64,7 +82,20 @@ export default {
{{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].title }}
</h5>
</div>
- <okr-actions-split-button />
+ <okr-actions-split-button
+ @showCreateObjectiveForm="
+ showAddForm($options.FORM_TYPES.create, $options.WORK_ITEM_TYPE_ENUM_OBJECTIVE)
+ "
+ @showAddObjectiveForm="
+ showAddForm($options.FORM_TYPES.add, $options.WORK_ITEM_TYPE_ENUM_OBJECTIVE)
+ "
+ @showCreateKeyResultForm="
+ showAddForm($options.FORM_TYPES.create, $options.WORK_ITEM_TYPE_ENUM_KEY_RESULT)
+ "
+ @showAddKeyResultForm="
+ showAddForm($options.FORM_TYPES.add, $options.WORK_ITEM_TYPE_ENUM_KEY_RESULT)
+ "
+ />
<div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-100 gl-pl-3 gl-ml-3">
<gl-button
category="tertiary"
@@ -87,6 +118,15 @@ export default {
{{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].empty }}
</p>
</div>
+ <work-item-links-form
+ v-if="isShownAddForm"
+ ref="wiLinksForm"
+ data-testid="add-tree-form"
+ :issuable-gid="workItemId"
+ :form-type="formType"
+ :children-type="childType"
+ @cancel="hideAddForm"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index 582532b51fd..cdb69586969 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -28,6 +28,7 @@ export const WORK_ITEM_TYPE_ENUM_TASK = 'TASK';
export const WORK_ITEM_TYPE_ENUM_TEST_CASE = 'TEST_CASE';
export const WORK_ITEM_TYPE_ENUM_REQUIREMENTS = 'REQUIREMENTS';
export const WORK_ITEM_TYPE_ENUM_OBJECTIVE = 'OBJECTIVE';
+export const WORK_ITEM_TYPE_ENUM_KEY_RESULT = 'KEY_RESULT';
export const WORK_ITEM_TYPE_VALUE_OBJECTIVE = 'Objective';
@@ -64,6 +65,13 @@ export const I18N_WORK_ITEM_FETCH_ITERATIONS_ERROR = s__(
'WorkItem|Something went wrong when fetching iterations. Please try again.',
);
+export const I18N_WORK_ITEM_CREATE_BUTTON_LABEL = s__('WorkItem|Create %{workItemType}');
+export const I18N_WORK_ITEM_ADD_BUTTON_LABEL = s__('WorkItem|Add %{workItemType}');
+export const I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL = s__('WorkItem|Add %{workItemType}s');
+export const I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER = s__(
+ 'WorkItem|Search existing %{workItemType}s',
+);
+
export const sprintfWorkItem = (msg, workItemTypeArg) => {
const workItemType = workItemTypeArg || s__('WorkItem|Work item');
return capitalizeFirstCharacter(
@@ -107,6 +115,10 @@ export const WORK_ITEMS_TYPE_MAP = {
icon: `issue-type-issue`,
name: s__('WorkItem|Objective'),
},
+ [WORK_ITEM_TYPE_ENUM_KEY_RESULT]: {
+ icon: `issue-type-issue`,
+ name: s__('WorkItem|Key result'),
+ },
};
export const WORK_ITEMS_TREE_TEXT_MAP = {
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
index 4fbcdfe2b96..e4d37382309 100644
--- a/app/assets/javascripts/work_items/index.js
+++ b/app/assets/javascripts/work_items/index.js
@@ -15,6 +15,7 @@ export const initWorkItemsRoot = () => {
apolloProvider,
provide: {
fullPath,
+ projectPath: fullPath,
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
issuesListPath,
hasIterationsFeature: parseBoolean(hasIterationsFeature),
diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb
index 75193309a4e..1d05cee02d4 100644
--- a/app/controllers/import/bitbucket_controller.rb
+++ b/app/controllers/import/bitbucket_controller.rb
@@ -49,6 +49,14 @@ class Import::BitbucketController < Import::BaseController
namespace_path = params[:new_namespace].presence || repo_owner
target_namespace = find_or_create_namespace(namespace_path, current_user)
+ Gitlab::Tracking.event(
+ self.class.name,
+ 'create',
+ label: 'import_access_level',
+ user: current_user,
+ extra: { user_role: user_role(current_user, target_namespace), import_type: 'bitbucket' }
+ )
+
if current_user.can?(:create_projects, target_namespace)
# The token in a session can be expired, we need to get most recent one because
# Bitbucket::Connection class refreshes it.
@@ -89,6 +97,21 @@ class Import::BitbucketController < Import::BaseController
private
+ def user_role(user, namespace)
+ if current_user.id == namespace&.owner_id
+ Gitlab::Access.options_with_owner.key(Gitlab::Access::OWNER)
+ else
+ access_level = current_user&.group_members&.find_by(source_id: namespace&.id)&.access_level
+
+ case access_level
+ when nil
+ 'Not a member'
+ else
+ Gitlab::Access.human_access(access_level)
+ end
+ end
+ end
+
def oauth_client
@oauth_client ||= OAuth2::Client.new(provider.app_id, provider.app_secret, options)
end
diff --git a/app/services/bulk_imports/create_service.rb b/app/services/bulk_imports/create_service.rb
index d3c6dcca588..0403069e5d4 100644
--- a/app/services/bulk_imports/create_service.rb
+++ b/app/services/bulk_imports/create_service.rb
@@ -62,6 +62,8 @@ module BulkImports
bulk_import.create_configuration!(credentials.slice(:url, :access_token))
Array.wrap(params).each do |entity|
+ track_access_level(entity)
+
BulkImports::Entity.create!(
bulk_import: bulk_import,
source_type: entity[:source_type],
@@ -75,6 +77,34 @@ module BulkImports
end
end
+ def track_access_level(entity)
+ Gitlab::Tracking.event(
+ self.class.name,
+ 'create',
+ label: 'import_access_level',
+ user: current_user,
+ extra: { user_role: user_role(entity[:destination_namespace]), import_type: 'bulk_import_group' }
+ )
+ end
+
+ def user_role(destination_namespace)
+ namespace = Namespace.find_by_full_path(destination_namespace)
+ # if there is no parent namespace we assume user will be group creator/owner
+ return owner_role unless destination_namespace
+ return owner_role unless namespace
+ return owner_role unless namespace.group_namespace? # user namespace
+
+ membership = current_user.group_members.find_by(source_id: namespace.id) # rubocop:disable CodeReuse/ActiveRecord
+
+ return 'Not a member' unless membership
+
+ Gitlab::Access.human_access(membership.access_level)
+ end
+
+ def owner_role
+ Gitlab::Access.human_access(Gitlab::Access::OWNER)
+ end
+
def client
@client ||= BulkImports::Clients::HTTP.new(
url: @credentials[:url],
diff --git a/app/services/groups/import_export/import_service.rb b/app/services/groups/import_export/import_service.rb
index 4092ded67bc..ac181245986 100644
--- a/app/services/groups/import_export/import_service.rb
+++ b/app/services/groups/import_export/import_service.rb
@@ -8,6 +8,7 @@ module Groups
def initialize(group:, user:)
@group = group
@current_user = user
+ @user_role = user_role
@shared = Gitlab::ImportExport::Shared.new(@group)
@logger = Gitlab::Import::Logger.build
end
@@ -31,6 +32,14 @@ module Groups
if valid_user_permissions? && import_file && restorers.all?(&:restore)
notify_success
+ Gitlab::Tracking.event(
+ self.class.name,
+ 'create',
+ label: 'import_access_level',
+ user: current_user,
+ extra: { user_role: user_role, import_type: 'import_group_from_file' }
+ )
+
group
else
notify_error!
@@ -43,6 +52,15 @@ module Groups
private
+ def user_role
+ # rubocop:disable CodeReuse/ActiveRecord, Style/MultilineTernaryOperator
+ access_level = group.parent ?
+ current_user&.group_members&.find_by(source_id: group.parent&.id)&.access_level :
+ Gitlab::Access::OWNER
+ Gitlab::Access.human_access(access_level)
+ # rubocop:enable CodeReuse/ActiveRecord, Style/MultilineTernaryOperator
+ end
+
def import_file
@import_file ||= Gitlab::ImportExport::FileImporter.import(
importable: group,
diff --git a/app/services/import/base_service.rb b/app/services/import/base_service.rb
index ab3e9c7abba..6b5adcbc39e 100644
--- a/app/services/import/base_service.rb
+++ b/app/services/import/base_service.rb
@@ -35,5 +35,30 @@ module Import
def success(project)
super().merge(project: project, status: :success)
end
+
+ def track_access_level(import_type)
+ Gitlab::Tracking.event(
+ self.class.name,
+ 'create',
+ label: 'import_access_level',
+ user: current_user,
+ extra: { user_role: user_role, import_type: import_type }
+ )
+ end
+
+ def user_role
+ if current_user.id == target_namespace.owner_id
+ 'Owner'
+ else
+ access_level = current_user&.group_members&.find_by(source_id: target_namespace.id)&.access_level
+
+ case access_level
+ when nil
+ 'Not a member'
+ else
+ Gitlab::Access.human_access(access_level)
+ end
+ end
+ end
end
end
diff --git a/app/services/import/bitbucket_server_service.rb b/app/services/import/bitbucket_server_service.rb
index 20f6c987c92..f7f17f1e53e 100644
--- a/app/services/import/bitbucket_server_service.rb
+++ b/app/services/import/bitbucket_server_service.rb
@@ -19,6 +19,8 @@ module Import
project = create_project(credentials)
+ track_access_level('bitbucket')
+
if project.persisted?
success(project)
elsif project.errors[:import_source_disabled].present?
diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb
index a60963e28c7..2378a4b11b1 100644
--- a/app/services/import/github_service.rb
+++ b/app/services/import/github_service.rb
@@ -13,6 +13,7 @@ module Import
return context_error if context_error
project = create_project(access_params, provider)
+ track_access_level('github')
if project.persisted?
store_import_settings(project)
diff --git a/app/views/admin/application_settings/_kroki.html.haml b/app/views/admin/application_settings/_kroki.html.haml
index 4f5a313d7b7..f1f6dd34401 100644
--- a/app/views/admin/application_settings/_kroki.html.haml
+++ b/app/views/admin/application_settings/_kroki.html.haml
@@ -24,7 +24,7 @@
- install_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: install_link_url }
= html_escape(_('Use the public cloud instance URL (%{kroki_public_url}) or %{install_link_start}install Kroki%{install_link_end} on your own infrastructure and use your own instance URL.')) % { kroki_public_url: '<code>https://kroki.io</code>'.html_safe, install_link_start: install_link_start, install_link_end: '</a>'.html_safe }
.form-group
- = f.label :kroki_formats, 'Additional diagram formats', class: 'label-bold'
+ = f.label :kroki_formats, _('Additional diagram formats'), class: 'label-bold'
.form-text.text-muted
- container_link_url = 'https://docs.kroki.io/kroki/setup/install/#images'
- container_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: container_link_url }
diff --git a/config/feature_flags/development/graphql_keyset_pagination_without_next_page_query.yml b/config/feature_flags/development/graphql_keyset_pagination_without_next_page_query.yml
deleted file mode 100644
index 7b4c884a82f..00000000000
--- a/config/feature_flags/development/graphql_keyset_pagination_without_next_page_query.yml
+++ /dev/null
@@ -1,8 +0,0 @@
----
-name: graphql_keyset_pagination_without_next_page_query
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/97509
-rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/373792
-milestone: '15.4'
-type: development
-group: group::optimize
-default_enabled: true
diff --git a/doc/user/project/merge_requests/reviews/index.md b/doc/user/project/merge_requests/reviews/index.md
index 4c503211513..3a1e0099f0f 100644
--- a/doc/user/project/merge_requests/reviews/index.md
+++ b/doc/user/project/merge_requests/reviews/index.md
@@ -71,6 +71,50 @@ if you [approve a merge request](../approvals/index.md#approve-a-merge-request)
are shown in the reviewer list, a green check mark **{check-circle-filled}**
displays next to your name.
+### Download merge request changes as a diff
+
+To download the changes included in a merge request as a diff:
+
+1. On the top bar, select **Main menu > Projects** and find your project.
+1. On the left sidebar, select **Merge requests**.
+1. On the top right, select **Code > Plain diff**.
+
+If you know the URL of the merge request, you can also download the diff from
+the command line by appending `.diff` to the URL. This example downloads the diff
+for merge request `000000`:
+
+```plaintext
+https://gitlab.com/gitlab-org/gitlab/-/merge_requests/000000.diff
+```
+
+To download and apply the diff in a one-line CLI command:
+
+```shell
+curl "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/000000.diff" | git apply
+```
+
+### Download merge request changes as a patch file
+
+To download the changes included in a merge request as a patch file:
+
+1. On the top bar, select **Main menu > Projects** and find your project.
+1. On the left sidebar, select **Merge requests**.
+1. On the top right, select **Code > Email patches**.
+
+If you know the URL of the merge request, you can also download the patch from
+the command line by appending `.patch` to the URL. This example downloads the patch
+file for merge request `000000`:
+
+```plaintext
+https://gitlab.com/gitlab-org/gitlab/-/merge_requests/000000.patch
+```
+
+To download and apply the patch in a one-line CLI command using [`git am`](https://git-scm.com/docs/git-am):
+
+```shell
+curl "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/000000.patch" | git am
+```
+
### Submit a review
You can submit your completed review in multiple ways:
diff --git a/lib/gitlab/graphql/pagination/keyset/connection.rb b/lib/gitlab/graphql/pagination/keyset/connection.rb
index eca4d42fb9a..208ca5f2d24 100644
--- a/lib/gitlab/graphql/pagination/keyset/connection.rb
+++ b/lib/gitlab/graphql/pagination/keyset/connection.rb
@@ -59,16 +59,7 @@ module Gitlab
if before
true
elsif first
- if Feature.enabled?(:graphql_keyset_pagination_without_next_page_query)
- limited_nodes.size > limit_value
- else
- case sliced_nodes
- when Array
- sliced_nodes.size > limit_value
- else
- sliced_nodes.limit(1).offset(limit_value).exists? # rubocop: disable CodeReuse/ActiveRecord
- end
- end
+ limited_nodes.size > limit_value
else
false
end
@@ -126,15 +117,9 @@ module Gitlab
@has_previous_page = paginated_nodes.count > limit_value
@has_previous_page ? paginated_nodes.last(limit_value) : paginated_nodes
elsif loaded?(sliced_nodes)
- if Feature.enabled?(:graphql_keyset_pagination_without_next_page_query)
- sliced_nodes.take(limit_value + 1) # rubocop: disable CodeReuse/ActiveRecord
- else
- sliced_nodes.take(limit_value) # rubocop: disable CodeReuse/ActiveRecord
- end
- elsif Feature.enabled?(:graphql_keyset_pagination_without_next_page_query)
- sliced_nodes.limit(limit_value + 1).to_a
+ sliced_nodes.take(limit_value + 1) # rubocop: disable CodeReuse/ActiveRecord
else
- sliced_nodes.limit(limit_value)
+ sliced_nodes.limit(limit_value + 1).to_a
end
end
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index c96477f64f8..55858daee9d 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -2464,6 +2464,9 @@ msgstr ""
msgid "Adding new applications is disabled in your GitLab instance. Please contact your GitLab administrator to get the permission"
msgstr ""
+msgid "Additional diagram formats"
+msgstr ""
+
msgid "Additional minutes"
msgstr ""
@@ -46324,6 +46327,12 @@ msgstr ""
msgid "WorkItem|Add"
msgstr ""
+msgid "WorkItem|Add %{workItemType}"
+msgstr ""
+
+msgid "WorkItem|Add %{workItemType}s"
+msgstr ""
+
msgid "WorkItem|Add a title"
msgstr ""
@@ -46339,12 +46348,6 @@ msgstr ""
msgid "WorkItem|Add start date"
msgstr ""
-msgid "WorkItem|Add task"
-msgstr ""
-
-msgid "WorkItem|Add tasks"
-msgstr ""
-
msgid "WorkItem|Add to iteration"
msgstr ""
@@ -46377,6 +46380,9 @@ msgstr ""
msgid "WorkItem|Collapse tasks"
msgstr ""
+msgid "WorkItem|Create %{workItemType}"
+msgstr ""
+
msgid "WorkItem|Create objective"
msgstr ""
@@ -46413,6 +46419,9 @@ msgstr ""
msgid "WorkItem|Iteration"
msgstr ""
+msgid "WorkItem|Key result"
+msgstr ""
+
msgid "WorkItem|Learn about tasks."
msgstr ""
@@ -46458,7 +46467,7 @@ msgstr ""
msgid "WorkItem|Requirements"
msgstr ""
-msgid "WorkItem|Search existing tasks"
+msgid "WorkItem|Search existing %{workItemType}s"
msgstr ""
msgid "WorkItem|Select type"
diff --git a/qa/README.md b/qa/README.md
index 564beb4c6e8..a0560e1f965 100644
--- a/qa/README.md
+++ b/qa/README.md
@@ -241,9 +241,8 @@ feature flag ([via the API](https://docs.gitlab.com/ee/api/features.html)) if no
run all the tests in the `Test::Instance::All` scenario, and then enable the
feature flag again if it was enabled earlier.
-Note: the QA framework doesn't currently allow you to easily toggle a feature
-flag during a single test, [as you can in unit tests](https://docs.gitlab.com/ee/development/feature_flags/index.html),
-but [that capability is planned](https://gitlab.com/gitlab-org/quality/team-tasks/issues/77).
+Note: You can also [toggle feature
+flags in the tests themselves](https://docs.gitlab.com/ee/development/testing_guide/end_to_end/feature_flags.html).
Note also that the `--` separator isn't used because `--enable-feature` and `--disable-feature`
are QA framework options, not `rspec` options.
diff --git a/spec/controllers/import/bitbucket_controller_spec.rb b/spec/controllers/import/bitbucket_controller_spec.rb
index e73e61b6ec5..35f712dc50d 100644
--- a/spec/controllers/import/bitbucket_controller_spec.rb
+++ b/spec/controllers/import/bitbucket_controller_spec.rb
@@ -185,6 +185,14 @@ RSpec.describe Import::BitbucketController do
post :create, format: :json
+ expect_snowplow_event(
+ category: 'Import::BitbucketController',
+ action: 'create',
+ label: 'import_access_level',
+ user: user,
+ extra: { user_role: 'Owner', import_type: 'bitbucket' }
+ )
+
expect(response).to have_gitlab_http_status(:ok)
end
@@ -297,6 +305,14 @@ RSpec.describe Import::BitbucketController do
.to receive(:new).and_return(double(execute: project))
expect { post :create, format: :json }.not_to change(Namespace, :count)
+
+ expect_snowplow_event(
+ category: 'Import::BitbucketController',
+ action: 'create',
+ label: 'import_access_level',
+ user: user,
+ extra: { user_role: 'Owner', import_type: 'bitbucket' }
+ )
end
it "takes the current user's namespace" do
@@ -417,6 +433,14 @@ RSpec.describe Import::BitbucketController do
post :create, params: { target_namespace: other_namespace.name }, format: :json
expect(response).to have_gitlab_http_status(:unprocessable_entity)
+
+ expect_snowplow_event(
+ category: 'Import::BitbucketController',
+ action: 'create',
+ label: 'import_access_level',
+ user: user,
+ extra: { user_role: 'Not a member', import_type: 'bitbucket' }
+ )
end
end
end
diff --git a/spec/frontend/groups/components/group_name_and_path_spec.js b/spec/frontend/groups/components/group_name_and_path_spec.js
index 823d2ed286a..9965b608f27 100644
--- a/spec/frontend/groups/components/group_name_and_path_spec.js
+++ b/spec/frontend/groups/components/group_name_and_path_spec.js
@@ -398,7 +398,7 @@ describe('GroupNameAndPath', () => {
expect(findAlert().exists()).toBe(true);
expect(findAlert().findByRole('link', { name: 'Learn more' }).attributes('href')).toBe(
- helpPagePath('user/group/index', {
+ helpPagePath('user/group/manage', {
anchor: 'change-a-groups-path',
}),
);
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js
index 0c5f2af1209..9c1e9ccb6e8 100644
--- a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js
@@ -1,7 +1,13 @@
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue';
+import WorkItemLinksForm from '~/work_items/components/work_item_links/work_item_links_form.vue';
import OkrActionsSplitButton from '~/work_items/components/work_item_links/okr_actions_split_button.vue';
+import {
+ FORM_TYPES,
+ WORK_ITEM_TYPE_ENUM_OBJECTIVE,
+ WORK_ITEM_TYPE_ENUM_KEY_RESULT,
+} from '~/work_items/constants';
describe('WorkItemTree', () => {
let wrapper;
@@ -10,10 +16,11 @@ describe('WorkItemTree', () => {
const findTreeBody = () => wrapper.findByTestId('tree-body');
const findEmptyState = () => wrapper.findByTestId('tree-empty');
const findToggleFormSplitButton = () => wrapper.findComponent(OkrActionsSplitButton);
+ const findForm = () => wrapper.findComponent(WorkItemLinksForm);
const createComponent = () => {
wrapper = shallowMountExtended(WorkItemTree, {
- propsData: { workItemType: 'Objective' },
+ propsData: { workItemType: 'Objective', workItemId: 'gid://gitlab/WorkItem/515' },
});
};
@@ -42,4 +49,26 @@ describe('WorkItemTree', () => {
it('displays empty state if there are no children', () => {
expect(findEmptyState().exists()).toBe(true);
});
+
+ it('does not display form by default', () => {
+ expect(findForm().exists()).toBe(false);
+ });
+
+ it.each`
+ option | event | formType | childType
+ ${'New objective'} | ${'showCreateObjectiveForm'} | ${FORM_TYPES.create} | ${WORK_ITEM_TYPE_ENUM_OBJECTIVE}
+ ${'Existing objective'} | ${'showAddObjectiveForm'} | ${FORM_TYPES.add} | ${WORK_ITEM_TYPE_ENUM_OBJECTIVE}
+ ${'New key result'} | ${'showCreateKeyResultForm'} | ${FORM_TYPES.create} | ${WORK_ITEM_TYPE_ENUM_KEY_RESULT}
+ ${'Existing key result'} | ${'showAddKeyResultForm'} | ${FORM_TYPES.add} | ${WORK_ITEM_TYPE_ENUM_KEY_RESULT}
+ `(
+ 'when selecting $option from split button, renders the form passing $formType and $childType',
+ async ({ event, formType, childType }) => {
+ findToggleFormSplitButton().vm.$emit(event);
+ await nextTick();
+
+ expect(findForm().exists()).toBe(true);
+ expect(findForm().props('formType')).toBe(formType);
+ expect(findForm().props('childrenType')).toBe(childType);
+ },
+ );
});
diff --git a/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb
index 1124868bdae..773df9b20ee 100644
--- a/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb
+++ b/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb
@@ -50,17 +50,16 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do
end
before do
- stub_feature_flags(graphql_keyset_pagination_without_next_page_query: false)
allow(GitlabSchema).to receive(:default_max_page_size).and_return(2)
end
- it 'invokes an extra query for the next page check' do
+ it 'invokes no an extra query for the next page check' do
arguments[:first] = 1
subject.nodes
count = ActiveRecord::QueryRecorder.new { subject.has_next_page }.count
- expect(count).to eq(1)
+ expect(count).to eq(0)
end
context 'when the relation is loaded' do
@@ -438,382 +437,4 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do
end
end
end
-
- # duplicated tests, remove with the removal of the graphql_keyset_pagination_without_next_page_query FF
- context 'when the graphql_keyset_pagination_without_next_page_query is on' do
- let(:nodes) { Project.all.order(Gitlab::Pagination::Keyset::Order.build([column_order_id])) }
-
- before do
- stub_feature_flags(graphql_keyset_pagination_without_next_page_query: true)
- end
-
- it 'does not invoke an extra query for the next page check' do
- arguments[:first] = 1
-
- subject.nodes
-
- count = ActiveRecord::QueryRecorder.new { subject.has_next_page }.count
- expect(count).to eq(0)
- end
-
- it_behaves_like 'a connection with collection methods'
-
- it_behaves_like 'a redactable connection' do
- let_it_be(:projects) { create_list(:project, 2) }
- let(:unwanted) { projects.second }
- end
-
- describe '#cursor_for' do
- let(:project) { create(:project) }
- let(:cursor) { connection.cursor_for(project) }
-
- it 'returns an encoded ID' do
- expect(decoded_cursor(cursor)).to eq('id' => project.id.to_s)
- end
-
- context 'when an order is specified' do
- let(:nodes) { Project.all.order(Gitlab::Pagination::Keyset::Order.build([column_order_id])) }
-
- it 'returns the encoded value of the order' do
- expect(decoded_cursor(cursor)).to include('id' => project.id.to_s)
- end
- end
-
- context 'when multiple orders are specified' do
- let(:nodes) { Project.all.order(Gitlab::Pagination::Keyset::Order.build([column_order_updated_at, column_order_created_at, column_order_id])) }
-
- it 'returns the encoded value of the order' do
- expect(decoded_cursor(cursor)).to include('updated_at' => project.updated_at.to_s(:inspect))
- end
- end
- end
-
- describe '#sliced_nodes' do
- let(:projects) { create_list(:project, 4) }
-
- context 'when before is passed' do
- let(:arguments) { { before: encoded_cursor(projects[1]) } }
-
- it 'only returns the project before the selected one' do
- expect(subject.sliced_nodes).to contain_exactly(projects.first)
- end
-
- context 'when the sort order is descending' do
- let(:nodes) { Project.all.order(Gitlab::Pagination::Keyset::Order.build([column_order_id_desc])) }
-
- it 'returns the correct nodes' do
- expect(subject.sliced_nodes).to contain_exactly(*projects[2..])
- end
- end
- end
-
- context 'when after is passed' do
- let(:arguments) { { after: encoded_cursor(projects[1]) } }
-
- it 'only returns the project before the selected one' do
- expect(subject.sliced_nodes).to contain_exactly(*projects[2..])
- end
-
- context 'when the sort order is descending' do
- let(:nodes) { Project.all.order(Gitlab::Pagination::Keyset::Order.build([column_order_id_desc])) }
-
- it 'returns the correct nodes' do
- expect(subject.sliced_nodes).to contain_exactly(projects.first)
- end
- end
- end
-
- context 'when both before and after are passed' do
- let(:arguments) do
- {
- after: encoded_cursor(projects[1]),
- before: encoded_cursor(projects[3])
- }
- end
-
- it 'returns the expected set' do
- expect(subject.sliced_nodes).to contain_exactly(projects[2])
- end
- end
-
- shared_examples 'nodes are in ascending order' do
- context 'when no cursor is passed' do
- let(:arguments) { {} }
-
- it 'returns projects in ascending order' do
- expect(subject.sliced_nodes).to eq(ascending_nodes)
- end
- end
-
- context 'when before cursor value is not NULL' do
- let(:arguments) { { before: encoded_cursor(ascending_nodes[2]) } }
-
- it 'returns all projects before the cursor' do
- expect(subject.sliced_nodes).to eq(ascending_nodes.first(2))
- end
- end
-
- context 'when after cursor value is not NULL' do
- let(:arguments) { { after: encoded_cursor(ascending_nodes[1]) } }
-
- it 'returns all projects after the cursor' do
- expect(subject.sliced_nodes).to eq(ascending_nodes.last(3))
- end
- end
-
- context 'when before and after cursor' do
- let(:arguments) { { before: encoded_cursor(ascending_nodes.last), after: encoded_cursor(ascending_nodes.first) } }
-
- it 'returns all projects after the cursor' do
- expect(subject.sliced_nodes).to eq(ascending_nodes[1..3])
- end
- end
- end
-
- shared_examples 'nodes are in descending order' do
- context 'when no cursor is passed' do
- let(:arguments) { {} }
-
- it 'only returns projects in descending order' do
- expect(subject.sliced_nodes).to eq(descending_nodes)
- end
- end
-
- context 'when before cursor value is not NULL' do
- let(:arguments) { { before: encoded_cursor(descending_nodes[2]) } }
-
- it 'returns all projects before the cursor' do
- expect(subject.sliced_nodes).to eq(descending_nodes.first(2))
- end
- end
-
- context 'when after cursor value is not NULL' do
- let(:arguments) { { after: encoded_cursor(descending_nodes[1]) } }
-
- it 'returns all projects after the cursor' do
- expect(subject.sliced_nodes).to eq(descending_nodes.last(3))
- end
- end
-
- context 'when before and after cursor' do
- let(:arguments) { { before: encoded_cursor(descending_nodes.last), after: encoded_cursor(descending_nodes.first) } }
-
- it 'returns all projects after the cursor' do
- expect(subject.sliced_nodes).to eq(descending_nodes[1..3])
- end
- end
- end
-
- context 'when multiple orders with nil values are defined' do
- let_it_be(:project1) { create(:project, last_repository_check_at: 10.days.ago) } # Asc: project5 Desc: project3
- let_it_be(:project2) { create(:project, last_repository_check_at: nil) } # Asc: project1 Desc: project1
- let_it_be(:project3) { create(:project, last_repository_check_at: 5.days.ago) } # Asc: project3 Desc: project5
- let_it_be(:project4) { create(:project, last_repository_check_at: nil) } # Asc: project2 Desc: project2
- let_it_be(:project5) { create(:project, last_repository_check_at: 20.days.ago) } # Asc: project4 Desc: project4
-
- context 'when ascending' do
- let_it_be(:order) { Gitlab::Pagination::Keyset::Order.build([column_order_last_repo, column_order_id]) }
- let_it_be(:nodes) { Project.order(order) }
- let_it_be(:ascending_nodes) { [project5, project1, project3, project2, project4] }
-
- it_behaves_like 'nodes are in ascending order'
-
- context 'when before cursor value is NULL' do
- let(:arguments) { { before: encoded_cursor(project4) } }
-
- it 'returns all projects before the cursor' do
- expect(subject.sliced_nodes).to eq([project5, project1, project3, project2])
- end
- end
-
- context 'when after cursor value is NULL' do
- let(:arguments) { { after: encoded_cursor(project2) } }
-
- it 'returns all projects after the cursor' do
- expect(subject.sliced_nodes).to eq([project4])
- end
- end
- end
-
- context 'when descending' do
- let_it_be(:order) { Gitlab::Pagination::Keyset::Order.build([column_order_last_repo_desc, column_order_id]) }
- let_it_be(:nodes) { Project.order(order) }
- let_it_be(:descending_nodes) { [project3, project1, project5, project2, project4] }
-
- it_behaves_like 'nodes are in descending order'
-
- context 'when before cursor value is NULL' do
- let(:arguments) { { before: encoded_cursor(project4) } }
-
- it 'returns all projects before the cursor' do
- expect(subject.sliced_nodes).to eq([project3, project1, project5, project2])
- end
- end
-
- context 'when after cursor value is NULL' do
- let(:arguments) { { after: encoded_cursor(project2) } }
-
- it 'returns all projects after the cursor' do
- expect(subject.sliced_nodes).to eq([project4])
- end
- end
- end
- end
-
- context 'when ordering by similarity' do
- let_it_be(:project1) { create(:project, name: 'test') }
- let_it_be(:project2) { create(:project, name: 'testing') }
- let_it_be(:project3) { create(:project, name: 'tests') }
- let_it_be(:project4) { create(:project, name: 'testing stuff') }
- let_it_be(:project5) { create(:project, name: 'test') }
-
- let_it_be(:nodes) do
- # Note: sorted_by_similarity_desc scope internally supports the generic keyset order.
- Project.sorted_by_similarity_desc('test', include_in_select: true)
- end
-
- let_it_be(:descending_nodes) { nodes.to_a }
-
- it_behaves_like 'nodes are in descending order'
- end
-
- context 'when an invalid cursor is provided' do
- let(:arguments) { { before: Base64Bp.urlsafe_encode64('invalidcursor', padding: false) } }
-
- it 'raises an error' do
- expect { subject.sliced_nodes }.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
- end
- end
- end
-
- describe '#nodes' do
- let_it_be(:all_nodes) { create_list(:project, 5) }
-
- let(:paged_nodes) { subject.nodes }
-
- it_behaves_like 'connection with paged nodes' do
- let(:paged_nodes_size) { 3 }
- end
-
- context 'when both are passed' do
- let(:arguments) { { first: 2, last: 2 } }
-
- it 'raises an error' do
- expect { paged_nodes }.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
- end
- end
-
- context 'when primary key is not in original order' do
- let(:nodes) { Project.order(last_repository_check_at: :desc) }
-
- it 'is added to end' do
- sliced = subject.sliced_nodes
-
- order_sql = sliced.order_values.last.to_sql
-
- expect(order_sql).to end_with(Project.arel_table[:id].desc.to_sql)
- end
- end
-
- context 'when there is no primary key' do
- before do
- stub_const('NoPrimaryKey', Class.new(ActiveRecord::Base))
- NoPrimaryKey.class_eval do
- self.table_name = 'no_primary_key'
- self.primary_key = nil
- end
- end
-
- let(:nodes) { NoPrimaryKey.all }
-
- it 'raises an error' do
- expect(NoPrimaryKey.primary_key).to be_nil
- expect { subject.sliced_nodes }.to raise_error(ArgumentError, 'Relation must have a primary key')
- end
- end
- end
-
- describe '#has_previous_page and #has_next_page' do
- # using a list of 5 items with a max_page of 3
- let_it_be(:project_list) { create_list(:project, 5) }
- let_it_be(:nodes) { Project.order(Gitlab::Pagination::Keyset::Order.build([column_order_id])) }
-
- context 'when default query' do
- let(:arguments) { {} }
-
- it 'has no previous, but a next' do
- expect(subject.has_previous_page).to be_falsey
- expect(subject.has_next_page).to be_truthy
- end
- end
-
- context 'when before is first item' do
- let(:arguments) { { before: encoded_cursor(project_list.first) } }
-
- it 'has no previous, but a next' do
- expect(subject.has_previous_page).to be_falsey
- expect(subject.has_next_page).to be_truthy
- end
- end
-
- describe 'using `before`' do
- context 'when before is the last item' do
- let(:arguments) { { before: encoded_cursor(project_list.last) } }
-
- it 'has no previous, but a next' do
- expect(subject.has_previous_page).to be_falsey
- expect(subject.has_next_page).to be_truthy
- end
- end
-
- context 'when before and last specified' do
- let(:arguments) { { before: encoded_cursor(project_list.last), last: 2 } }
-
- it 'has a previous and a next' do
- expect(subject.has_previous_page).to be_truthy
- expect(subject.has_next_page).to be_truthy
- end
- end
-
- context 'when before and last does request all remaining nodes' do
- let(:arguments) { { before: encoded_cursor(project_list[1]), last: 3 } }
-
- it 'has a previous and a next' do
- expect(subject.has_previous_page).to be_falsey
- expect(subject.has_next_page).to be_truthy
- expect(subject.nodes).to eq [project_list[0]]
- end
- end
- end
-
- describe 'using `after`' do
- context 'when after is the first item' do
- let(:arguments) { { after: encoded_cursor(project_list.first) } }
-
- it 'has a previous, and a next' do
- expect(subject.has_previous_page).to be_truthy
- expect(subject.has_next_page).to be_truthy
- end
- end
-
- context 'when after and first specified' do
- let(:arguments) { { after: encoded_cursor(project_list.first), first: 2 } }
-
- it 'has a previous and a next' do
- expect(subject.has_previous_page).to be_truthy
- expect(subject.has_next_page).to be_truthy
- end
- end
-
- context 'when before and last does request all remaining nodes' do
- let(:arguments) { { after: encoded_cursor(project_list[2]), last: 3 } }
-
- it 'has a previous but no next' do
- expect(subject.has_previous_page).to be_truthy
- expect(subject.has_next_page).to be_falsey
- end
- end
- end
- end
- end
end
diff --git a/spec/services/bulk_imports/create_service_spec.rb b/spec/services/bulk_imports/create_service_spec.rb
index bf174f5d5a2..cb7d51ce7b4 100644
--- a/spec/services/bulk_imports/create_service_spec.rb
+++ b/spec/services/bulk_imports/create_service_spec.rb
@@ -5,6 +5,8 @@ require 'spec_helper'
RSpec.describe BulkImports::CreateService do
let(:user) { create(:user) }
let(:credentials) { { url: 'http://gitlab.example', access_token: 'token' } }
+ let(:destination_group) { create(:group, path: 'destination1') }
+ let_it_be(:parent_group) { create(:group, path: 'parent-group') }
let(:params) do
[
{
@@ -43,6 +45,7 @@ RSpec.describe BulkImports::CreateService do
end
it 'creates bulk import' do
+ parent_group.add_owner(user)
expect { subject.execute }.to change { BulkImport.count }.by(1)
last_bulk_import = BulkImport.last
@@ -50,11 +53,20 @@ RSpec.describe BulkImports::CreateService do
expect(last_bulk_import.user).to eq(user)
expect(last_bulk_import.source_version).to eq(source_version.to_s)
expect(last_bulk_import.user).to eq(user)
+
expect_snowplow_event(
category: 'BulkImports::CreateService',
action: 'create',
label: 'bulk_import_group'
)
+
+ expect_snowplow_event(
+ category: 'BulkImports::CreateService',
+ action: 'create',
+ label: 'import_access_level',
+ user: user,
+ extra: { user_role: 'Owner', import_type: 'bulk_import_group' }
+ )
end
it 'creates bulk import entities' do
@@ -87,5 +99,109 @@ RSpec.describe BulkImports::CreateService do
expect(result).to be_error
expect(result.message).to eq("Validation failed: Source full path can't be blank")
end
+
+ describe '#user-role' do
+ context 'when there is a parent_namespace and the user is a member' do
+ let(:group2) { create(:group, path: 'destination200', source_id: parent_group.id ) }
+ let(:params) do
+ [
+ {
+ source_type: 'group_entity',
+ source_full_path: 'full/path/to/group1',
+ destination_slug: 'destination200',
+ destination_namespace: 'parent-group'
+ }
+ ]
+ end
+
+ it 'defines access_level from parent namespace membership' do
+ parent_group.add_guest(user)
+ subject.execute
+
+ expect_snowplow_event(
+ category: 'BulkImports::CreateService',
+ action: 'create',
+ label: 'import_access_level',
+ user: user,
+ extra: { user_role: 'Guest', import_type: 'bulk_import_group' }
+ )
+ end
+ end
+
+ context 'when there is a parent_namespace and the user is not a member' do
+ let(:params) do
+ [
+ {
+ source_type: 'group_entity',
+ source_full_path: 'full/path/to/group1',
+ destination_slug: 'destination-group-1',
+ destination_namespace: 'parent-group'
+ }
+ ]
+ end
+
+ it 'defines access_level as not a member' do
+ subject.execute
+ expect_snowplow_event(
+ category: 'BulkImports::CreateService',
+ action: 'create',
+ label: 'import_access_level',
+ user: user,
+ extra: { user_role: 'Not a member', import_type: 'bulk_import_group' }
+ )
+ end
+ end
+
+ context 'when there is a destination_namespace but no parent_namespace' do
+ let(:params) do
+ [
+ {
+ source_type: 'group_entity',
+ source_full_path: 'full/path/to/group1',
+ destination_slug: 'destination-group-1',
+ destination_namespace: 'destination1'
+ }
+ ]
+ end
+
+ it 'defines access_level from destination_namespace' do
+ destination_group.add_developer(user)
+ subject.execute
+
+ expect_snowplow_event(
+ category: 'BulkImports::CreateService',
+ action: 'create',
+ label: 'import_access_level',
+ user: user,
+ extra: { user_role: 'Developer', import_type: 'bulk_import_group' }
+ )
+ end
+ end
+
+ context 'when there is no destination_namespace or parent_namespace' do
+ let(:params) do
+ [
+ {
+ source_type: 'group_entity',
+ source_full_path: 'full/path/to/group1',
+ destination_slug: 'destinationational mcdestiny',
+ destination_namespace: 'destinational-mcdestiny'
+ }
+ ]
+ end
+
+ it 'defines access_level as owner' do
+ subject.execute
+
+ expect_snowplow_event(
+ category: 'BulkImports::CreateService',
+ action: 'create',
+ label: 'import_access_level',
+ user: user,
+ extra: { user_role: 'Owner', import_type: 'bulk_import_group' }
+ )
+ end
+ end
+ end
end
end
diff --git a/spec/services/groups/import_export/import_service_spec.rb b/spec/services/groups/import_export/import_service_spec.rb
index 66b50704939..d41acbcc2de 100644
--- a/spec/services/groups/import_export/import_service_spec.rb
+++ b/spec/services/groups/import_export/import_service_spec.rb
@@ -148,6 +148,14 @@ RSpec.describe Groups::ImportExport::ImportService do
action: 'create',
label: 'import_group_from_file'
)
+
+ expect_snowplow_event(
+ category: 'Groups::ImportExport::ImportService',
+ action: 'create',
+ label: 'import_access_level',
+ user: user,
+ extra: { user_role: 'Owner', import_type: 'import_group_from_file' }
+ )
end
it 'removes import file' do
@@ -235,6 +243,14 @@ RSpec.describe Groups::ImportExport::ImportService do
)
service.execute
+
+ expect_snowplow_event(
+ category: 'Groups::ImportExport::ImportService',
+ action: 'create',
+ label: 'import_access_level',
+ user: user,
+ extra: { user_role: 'Owner', import_type: 'import_group_from_file' }
+ )
end
end
end
@@ -275,6 +291,14 @@ RSpec.describe Groups::ImportExport::ImportService do
action: 'create',
label: 'import_group_from_file'
)
+
+ expect_snowplow_event(
+ category: 'Groups::ImportExport::ImportService',
+ action: 'create',
+ label: 'import_access_level',
+ user: user,
+ extra: { user_role: 'Owner', import_type: 'import_group_from_file' }
+ )
end
it 'removes import file' do
@@ -352,6 +376,24 @@ RSpec.describe Groups::ImportExport::ImportService do
expect(service.execute).to be_truthy
end
+ it 'tracks the event' do
+ service.execute
+
+ expect_snowplow_event(
+ category: 'Groups::ImportExport::ImportService',
+ action: 'create',
+ label: 'import_group_from_file'
+ )
+
+ expect_snowplow_event(
+ category: 'Groups::ImportExport::ImportService',
+ action: 'create',
+ label: 'import_access_level',
+ user: user,
+ extra: { user_role: 'Owner', import_type: 'import_group_from_file' }
+ )
+ end
+
it 'logs the import success' do
allow(Gitlab::Import::Logger).to receive(:build).and_return(import_logger)
diff --git a/spec/services/import/bitbucket_server_service_spec.rb b/spec/services/import/bitbucket_server_service_spec.rb
index 0b9fe10e95a..555812ca9cf 100644
--- a/spec/services/import/bitbucket_server_service_spec.rb
+++ b/spec/services/import/bitbucket_server_service_spec.rb
@@ -31,6 +31,25 @@ RSpec.describe Import::BitbucketServerService do
allow(subject).to receive(:authorized?).and_return(true)
end
+ context 'execute' do
+ before do
+ allow(subject).to receive(:authorized?).and_return(true)
+ allow(client).to receive(:repo).with(project_key, repo_slug).and_return(double(repo))
+ end
+
+ it 'tracks an access level event' do
+ subject.execute(credentials)
+
+ expect_snowplow_event(
+ category: 'Import::BitbucketServerService',
+ action: 'create',
+ label: 'import_access_level',
+ user: user,
+ extra: { import_type: 'bitbucket', user_role: 'Owner' }
+ )
+ end
+ end
+
context 'when no repo is found' do
before do
allow(subject).to receive(:authorized?).and_return(true)
diff --git a/spec/services/import/github_service_spec.rb b/spec/services/import/github_service_spec.rb
index 38d84009f08..d1b372c5e87 100644
--- a/spec/services/import/github_service_spec.rb
+++ b/spec/services/import/github_service_spec.rb
@@ -82,9 +82,16 @@ RSpec.describe Import::GithubService do
end
context 'when there is no repository size limit defined' do
- it 'skips the check and succeeds' do
+ it 'skips the check, succeeds, and tracks an access level' do
expect(subject.execute(access_params, :github)).to include(status: :success)
expect(settings).to have_received(:write).with(nil)
+ expect_snowplow_event(
+ category: 'Import::GithubService',
+ action: 'create',
+ label: 'import_access_level',
+ user: user,
+ extra: { import_type: 'github', user_role: 'Owner' }
+ )
end
end
@@ -98,6 +105,13 @@ RSpec.describe Import::GithubService do
it 'succeeds when the repository is smaller than the limit' do
expect(subject.execute(access_params, :github)).to include(status: :success)
expect(settings).to have_received(:write).with(nil)
+ expect_snowplow_event(
+ category: 'Import::GithubService',
+ action: 'create',
+ label: 'import_access_level',
+ user: user,
+ extra: { import_type: 'github', user_role: 'Not a member' }
+ )
end
it 'returns error when the repository is larger than the limit' do
@@ -118,6 +132,13 @@ RSpec.describe Import::GithubService do
it 'succeeds when the repository is smaller than the limit' do
expect(subject.execute(access_params, :github)).to include(status: :success)
expect(settings).to have_received(:write).with(nil)
+ expect_snowplow_event(
+ category: 'Import::GithubService',
+ action: 'create',
+ label: 'import_access_level',
+ user: user,
+ extra: { import_type: 'github', user_role: 'Owner' }
+ )
end
it 'returns error when the repository is larger than the limit' do