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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-11-15 06:11:59 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-11-15 06:11:59 +0300
commit29c6745feab7edf3c0485b34b5e5fffdded9c402 (patch)
tree0953ef194cd5a2cd4ad750804ffe135539b46f6b
parentff45b1aea7367cf98a5b70e8324e4826c119bf87 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.rubocop_todo/rspec/named_subject.yml1
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue13
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue3
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item_skeleton.vue17
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue51
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue13
-rw-r--r--app/assets/javascripts/super_sidebar/graphql/queries/current_user_frecent_groups.query.graphql9
-rw-r--r--app/assets/javascripts/super_sidebar/graphql/queries/current_user_frecent_projects.query.graphql9
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_bundle.js8
-rw-r--r--app/controllers/projects/issue_links_controller.rb2
-rw-r--r--app/policies/issue_policy.rb5
-rw-r--r--app/policies/project_policy.rb1
-rw-r--r--app/serializers/linked_project_issue_entity.rb30
-rw-r--r--app/services/issue_links/create_service.rb30
-rw-r--r--app/services/issue_links/destroy_service.rb4
-rw-r--r--doc/api/graphql/index.md12
-rw-r--r--doc/api/graphql/reference/index.md10
-rw-r--r--doc/ci/testing/code_quality.md4
-rw-r--r--doc/ci/testing/unit_test_reports.md6
-rw-r--r--doc/ci/yaml/index.md35
-rw-r--r--doc/development/api_graphql_styleguide.md11
-rw-r--r--doc/user/application_security/policies/scan-result-policies.md34
-rw-r--r--doc/user/application_security/vulnerabilities/index.md2
-rw-r--r--doc/user/group/import/index.md2
-rw-r--r--doc/user/permissions.md2
-rw-r--r--doc/user/project/issues/related_issues.md2
-rw-r--r--lib/gitlab/application_rate_limiter.rb4
-rw-r--r--lib/gitlab/bitbucket_server_import/importers/pull_requests_importer.rb26
-rw-r--r--lib/gitlab/gon_helper.rb1
-rw-r--r--locale/gitlab.pot7
-rw-r--r--rubocop/rubocop-code_reuse.yml1
-rw-r--r--spec/features/issues/related_issues_spec.rb41
-rw-r--r--spec/frontend/super_sidebar/components/global_search/components/frequent_groups_spec.js49
-rw-r--r--spec/frontend/super_sidebar/components/global_search/components/frequent_item_spec.js17
-rw-r--r--spec/frontend/super_sidebar/components/global_search/components/frequent_items_spec.js207
-rw-r--r--spec/frontend/super_sidebar/components/global_search/components/frequent_projects_spec.js49
-rw-r--r--spec/frontend/super_sidebar/mock_data.js20
-rw-r--r--spec/lib/gitlab/bitbucket_server_import/importers/pull_requests_importer_spec.rb35
-rw-r--r--spec/policies/issue_policy_spec.rb57
-rw-r--r--spec/requests/api/issue_links_spec.rb10
-rw-r--r--spec/requests/projects/issue_links_controller_spec.rb42
-rw-r--r--spec/services/issue_links/create_service_spec.rb1
-rw-r--r--spec/services/issue_links/list_service_spec.rb13
-rw-r--r--spec/services/issues/create_service_spec.rb5
-rw-r--r--spec/support/shared_contexts/policies/project_policy_shared_context.rb2
-rw-r--r--spec/support/shared_examples/policies/project_policy_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/services/issuable_links/create_links_shared_examples.rb10
48 files changed, 667 insertions, 250 deletions
diff --git a/.rubocop_todo/rspec/named_subject.yml b/.rubocop_todo/rspec/named_subject.yml
index 45c9cf90b03..c99d6784b73 100644
--- a/.rubocop_todo/rspec/named_subject.yml
+++ b/.rubocop_todo/rspec/named_subject.yml
@@ -1097,7 +1097,6 @@ RSpec/NamedSubject:
- 'ee/spec/services/llm/generate_test_file_service_spec.rb'
- 'ee/spec/services/llm/git_command_service_spec.rb'
- 'ee/spec/services/llm/resolve_vulnerability_service_spec.rb'
- - 'ee/spec/services/llm/tanuki_bot_service_spec.rb'
- 'ee/spec/services/member_roles/create_service_spec.rb'
- 'ee/spec/services/merge_request_approval_settings/update_service_spec.rb'
- 'ee/spec/services/merge_requests/merge_service_spec.rb'
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index fb33000d1c1..e53a8e9c1eb 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-abb22ce3654f045c0c8b6fec1ba8b7a09eaca5f5
+fc7f05c95d10184fa76b2e613779bce4e46dd190
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue
index 6f0a0a1fe79..e7e7e54ce77 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue
@@ -1,13 +1,24 @@
<script>
import { s__ } from '~/locale';
import { MAX_FREQUENT_GROUPS_COUNT } from '~/super_sidebar/constants';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import currentUserFrecentGroupsQuery from '~/super_sidebar/graphql/queries/current_user_frecent_groups.query.graphql';
import FrequentItems from './frequent_items.vue';
export default {
name: 'FrequentlyVisitedGroups',
+ apollo: {
+ frecentGroups: {
+ query: currentUserFrecentGroupsQuery,
+ skip() {
+ return !this.glFeatures.frecentNamespacesSuggestions;
+ },
+ },
+ },
components: {
FrequentItems,
},
+ mixins: [glFeatureFlagsMixin()],
inject: ['groupsPath'],
data() {
const username = gon.current_username;
@@ -27,10 +38,12 @@ export default {
<template>
<frequent-items
+ :loading="$apollo.queries.frecentGroups.loading"
:empty-state-text="$options.i18n.emptyStateText"
:group-name="$options.i18n.groupName"
:max-items="$options.MAX_FREQUENT_GROUPS_COUNT"
:storage-key="storageKey"
+ :items="frecentGroups"
view-all-items-icon="group"
:view-all-items-text="$options.i18n.viewAllText"
:view-all-items-path="groupsPath"
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue
index 5371887ee0f..b0007c21cdc 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue
@@ -2,6 +2,7 @@
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
import { __ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
name: 'FrequentlyVisitedItem',
@@ -12,6 +13,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
item: {
type: Object,
@@ -51,6 +53,7 @@ export default {
</div>
<gl-button
+ v-if="!glFeatures.frecentNamespacesSuggestions"
v-gl-tooltip.left
icon="dash"
category="tertiary"
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item_skeleton.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item_skeleton.vue
new file mode 100644
index 00000000000..dce18b2c46e
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item_skeleton.vue
@@ -0,0 +1,17 @@
+<script>
+import { GlSkeletonLoader } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlSkeletonLoader,
+ },
+};
+</script>
+
+<template>
+ <gl-skeleton-loader :width="737" :height="48">
+ <rect width="24" height="24" y="12" x="8" />
+ <rect width="120" height="12" y="10" x="36" />
+ <rect width="100" height="12" y="26" x="36" />
+ </gl-skeleton-loader>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue
index ddadd6856ca..2e431c4f8da 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue
@@ -3,8 +3,10 @@ import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlIcon } from '@gi
import { truncateNamespace } from '~/lib/utils/text_utility';
import { getItemsFromLocalStorage, removeItemFromLocalStorage } from '~/super_sidebar/utils';
import { TRACKING_UNKNOWN_PANEL } from '~/super_sidebar/constants';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { TRACKING_CLICK_COMMAND_PALETTE_ITEM } from '../command_palette/constants';
import FrequentItem from './frequent_item.vue';
+import FrequentItemSkeleton from './frequent_item_skeleton.vue';
export default {
name: 'FrequentlyVisitedItems',
@@ -13,8 +15,15 @@ export default {
GlDisclosureDropdownItem,
GlIcon,
FrequentItem,
+ FrequentItemSkeleton,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
emptyStateText: {
type: String,
required: true,
@@ -45,10 +54,15 @@ export default {
required: false,
default: null,
},
+ items: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
data() {
return {
- items: getItemsFromLocalStorage({
+ localItems: getItemsFromLocalStorage({
storageKey: this.storageKey,
maxItems: this.maxItems,
}),
@@ -56,10 +70,11 @@ export default {
},
computed: {
formattedItems() {
+ const items = this.glFeatures.frecentNamespacesSuggestions ? this.items : this.localItems;
// Each item needs two different representations. One is for the
// GlDisclosureDropdownItem, and the other is for the FrequentItem
// renderer component inside it.
- return this.items.map((item) => ({
+ return items.map((item) => ({
forDropdown: {
id: item.id,
@@ -83,7 +98,7 @@ export default {
}));
},
showEmptyState() {
- return this.items.length === 0;
+ return !this.loading && this.formattedItems.length === 0;
},
viewAllItem() {
return {
@@ -93,18 +108,21 @@ export default {
},
},
created() {
- if (!this.storageKey) {
+ if (!this.glFeatures.frecentNamespacesSuggestions && !this.storageKey) {
this.$emit('nothing-to-render');
}
},
methods: {
removeItem(item) {
+ if (this.glFeatures.frecentNamespacesSuggestions) {
+ return;
+ }
removeItemFromLocalStorage({
storageKey: this.storageKey,
item,
});
- this.items = this.items.filter((i) => i.id !== item.id);
+ this.localItems = this.localItems.filter((i) => i.id !== item.id);
},
},
};
@@ -114,16 +132,21 @@ export default {
<gl-disclosure-dropdown-group v-if="storageKey" v-bind="$attrs">
<template #group-label>{{ groupName }}</template>
- <gl-disclosure-dropdown-item
- v-for="item of formattedItems"
- :key="item.forDropdown.id"
- :item="item.forDropdown"
- class="show-on-focus-or-hover--context"
- >
- <template #list-item
- ><frequent-item :item="item.forRenderer" @remove="removeItem"
- /></template>
+ <gl-disclosure-dropdown-item v-if="loading">
+ <frequent-item-skeleton />
</gl-disclosure-dropdown-item>
+ <template v-else>
+ <gl-disclosure-dropdown-item
+ v-for="item of formattedItems"
+ :key="item.forDropdown.id"
+ :item="item.forDropdown"
+ class="show-on-focus-or-hover--context"
+ >
+ <template #list-item
+ ><frequent-item :item="item.forRenderer" @remove="removeItem"
+ /></template>
+ </gl-disclosure-dropdown-item>
+ </template>
<gl-disclosure-dropdown-item v-if="showEmptyState" class="gl-cursor-text">
<span class="gl-text-gray-500 gl-font-sm gl-my-3 gl-mx-3">{{ emptyStateText }}</span>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue
index 35b254099c2..4a269d1b876 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue
@@ -1,13 +1,24 @@
<script>
import { s__ } from '~/locale';
import { MAX_FREQUENT_PROJECTS_COUNT } from '~/super_sidebar/constants';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import currentUserFrecentProjectsQuery from '~/super_sidebar/graphql/queries/current_user_frecent_projects.query.graphql';
import FrequentItems from './frequent_items.vue';
export default {
name: 'FrequentlyVisitedProjects',
+ apollo: {
+ frecentProjects: {
+ query: currentUserFrecentProjectsQuery,
+ skip() {
+ return !this.glFeatures.frecentNamespacesSuggestions;
+ },
+ },
+ },
components: {
FrequentItems,
},
+ mixins: [glFeatureFlagsMixin()],
inject: ['projectsPath'],
data() {
const username = gon.current_username;
@@ -27,10 +38,12 @@ export default {
<template>
<frequent-items
+ :loading="$apollo.queries.frecentProjects.loading"
:empty-state-text="$options.i18n.emptyStateText"
:group-name="$options.i18n.groupName"
:max-items="$options.MAX_FREQUENT_PROJECTS_COUNT"
:storage-key="storageKey"
+ :items="frecentProjects"
view-all-items-icon="project"
:view-all-items-text="$options.i18n.viewAllText"
:view-all-items-path="projectsPath"
diff --git a/app/assets/javascripts/super_sidebar/graphql/queries/current_user_frecent_groups.query.graphql b/app/assets/javascripts/super_sidebar/graphql/queries/current_user_frecent_groups.query.graphql
new file mode 100644
index 00000000000..82b9a53c36e
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/graphql/queries/current_user_frecent_groups.query.graphql
@@ -0,0 +1,9 @@
+query CurrentUserFrecentGroups {
+ frecentGroups {
+ id
+ name
+ namespace: fullName
+ webUrl
+ avatarUrl
+ }
+}
diff --git a/app/assets/javascripts/super_sidebar/graphql/queries/current_user_frecent_projects.query.graphql b/app/assets/javascripts/super_sidebar/graphql/queries/current_user_frecent_projects.query.graphql
new file mode 100644
index 00000000000..4b406d1ea6c
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/graphql/queries/current_user_frecent_projects.query.graphql
@@ -0,0 +1,9 @@
+query CurrentUserFrecentProjects {
+ frecentProjects {
+ id
+ name
+ namespace: nameWithNamespace
+ webUrl
+ avatarUrl
+ }
+}
diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
index 9e540175b48..b4ad9a20b76 100644
--- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
+++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
@@ -1,6 +1,8 @@
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
+import VueApollo from 'vue-apollo';
import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
+import createDefaultClient from '~/lib/graphql';
import { initStatusTriggers } from '../header';
import { JS_TOGGLE_EXPAND_CLASS } from './constants';
import createStore from './components/global_search/store';
@@ -12,6 +14,11 @@ import SuperSidebar from './components/super_sidebar.vue';
import SuperSidebarToggle from './components/super_sidebar_toggle.vue';
Vue.use(GlToast);
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
const getTrialStatusWidgetData = (sidebarData) => {
if (sidebarData.trial_status_widget_data_attrs && sidebarData.trial_status_popover_data_attrs) {
@@ -90,6 +97,7 @@ export const initSuperSidebar = () => {
return new Vue({
el,
name: 'SuperSidebarRoot',
+ apolloProvider,
provide: {
rootPath,
isImpersonating,
diff --git a/app/controllers/projects/issue_links_controller.rb b/app/controllers/projects/issue_links_controller.rb
index 956557457fa..4a5f9309aed 100644
--- a/app/controllers/projects/issue_links_controller.rb
+++ b/app/controllers/projects/issue_links_controller.rb
@@ -13,7 +13,7 @@ module Projects
private
def authorize_admin_issue_link!
- render_403 unless can?(current_user, :admin_issue_link, @project)
+ render_403 unless can?(current_user, :admin_issue_link, issue)
end
def authorize_issue_link_association!
diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb
index 683c53d8d78..dfb33fb386a 100644
--- a/app/policies/issue_policy.rb
+++ b/app/policies/issue_policy.rb
@@ -80,6 +80,7 @@ class IssuePolicy < IssuablePolicy
rule { ~anonymous & can?(:read_issue) }.policy do
enable :create_todo
enable :update_subscription
+ enable :create_issue_link
end
rule { can?(:admin_issue) }.policy do
@@ -103,6 +104,10 @@ class IssuePolicy < IssuablePolicy
enable :admin_issue_relation
end
+ rule { can?(:guest_access) & can?(:read_issue) & is_project_member }.policy do
+ enable :admin_issue_link
+ end
+
rule { can_read_crm_contacts }.policy do
enable :read_crm_contacts
end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index bbb0e3df500..e0e04174110 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -379,7 +379,6 @@ class ProjectPolicy < BasePolicy
enable :admin_label
enable :admin_milestone
enable :admin_issue_board_list
- enable :admin_issue_link
enable :read_commit_status
enable :read_build
enable :read_container_image
diff --git a/app/serializers/linked_project_issue_entity.rb b/app/serializers/linked_project_issue_entity.rb
index c95f68f58a3..28dca69a8d4 100644
--- a/app/serializers/linked_project_issue_entity.rb
+++ b/app/serializers/linked_project_issue_entity.rb
@@ -4,10 +4,11 @@ class LinkedProjectIssueEntity < LinkedIssueEntity
include Gitlab::Utils::StrongMemoize
expose :relation_path, override: true do |issue|
- # Make sure the user can admin both the current issue AND the
- # referenced issue projects in order to return the removal link.
- if can_admin_issue_link_on_current_project? && can_admin_issue_link?(issue.project)
- project_issue_link_path(issuable.project, issuable.iid, issue.issue_link_id)
+ # Make sure the user can admin the links of one issue and
+ # create links in the other issue in order to return the removal link.
+ if can_create_or_destroy_issue_link?(issue)
+ project_issue_link_path(issuable.project, issuable.iid,
+ issue.issue_link_id)
end
end
@@ -17,13 +18,24 @@ class LinkedProjectIssueEntity < LinkedIssueEntity
private
- def can_admin_issue_link_on_current_project?
- strong_memoize(:can_admin_on_current_project) do
- can_admin_issue_link?(issuable.project)
+ # A user can create/destroy an issue link if they can
+ # admin the links for one issue AND create links for the other
+ def can_create_or_destroy_issue_link?(issue)
+ (can_admin_issue_link?(issuable) && can_create_issue_link?(issue)) ||
+ (can_admin_issue_link?(issue) && can_create_issue_link?(issuable))
+ end
+
+ def can_admin_issue_link_on_current_issue?
+ strong_memoize(:can_admin_on_current_issue) do
+ can_admin_issue_link?(issuable)
end
end
- def can_admin_issue_link?(project)
- Ability.allowed?(current_user, :admin_issue_link, project)
+ def can_admin_issue_link?(issue)
+ Ability.allowed?(current_user, :admin_issue_link, issue)
+ end
+
+ def can_create_issue_link?(issue)
+ Ability.allowed?(current_user, :create_issue_link, issue)
end
end
diff --git a/app/services/issue_links/create_service.rb b/app/services/issue_links/create_service.rb
index 3523e945d37..d4ef35d3c60 100644
--- a/app/services/issue_links/create_service.rb
+++ b/app/services/issue_links/create_service.rb
@@ -4,8 +4,17 @@ module IssueLinks
class CreateService < IssuableLinks::CreateService
include IncidentManagement::UsageData
+ def execute
+ return error(issue_no_permission_error_message, 403) unless can?(current_user, :admin_issue_link, issuable) ||
+ can?(current_user, :create_issue_link, issuable)
+
+ super
+ end
+
def linkable_issuables(issues)
- @linkable_issuables ||= issues.select { |issue| can?(current_user, :admin_issue_link, issue) }
+ @linkable_issuables ||= issues.select do |issue|
+ can_create_destroy_issue_link?(issue)
+ end
end
def previous_related_issuables
@@ -14,6 +23,21 @@ module IssueLinks
private
+ # A user can create/destroy an issue link if they can
+ # admin the links for one issue AND create links for the other
+ def can_create_destroy_issue_link?(issue)
+ (can_admin_issue_link?(issuable) && can_create_issue_link?(issue)) ||
+ (can_admin_issue_link?(issue) && can_create_issue_link?(issuable))
+ end
+
+ def can_admin_issue_link?(issue)
+ Ability.allowed?(current_user, :admin_issue_link, issue)
+ end
+
+ def can_create_issue_link?(issue)
+ Ability.allowed?(current_user, :create_issue_link, issue)
+ end
+
def readonly_issuables(issuables)
@readonly_issuables ||= issuables.select { |issuable| issuable.readable_by?(current_user) }
end
@@ -25,6 +49,10 @@ module IssueLinks
def link_class
IssueLink
end
+
+ def issue_no_permission_error_message
+ _("Couldn't link issues. You must have at least the Guest role in the source issue's project.")
+ end
end
end
diff --git a/app/services/issue_links/destroy_service.rb b/app/services/issue_links/destroy_service.rb
index 9116e9fb703..2281bebcb86 100644
--- a/app/services/issue_links/destroy_service.rb
+++ b/app/services/issue_links/destroy_service.rb
@@ -3,11 +3,13 @@
module IssueLinks
class DestroyService < IssuableLinks::DestroyService
include IncidentManagement::UsageData
+ include Gitlab::Utils::StrongMemoize
private
def permission_to_remove_relation?
- can?(current_user, :admin_issue_link, source) && can?(current_user, :admin_issue_link, target)
+ (can?(current_user, :admin_issue_link, link.source) && can?(current_user, :create_issue_link, link.target)) ||
+ (can?(current_user, :admin_issue_link, link.target) && can?(current_user, :create_issue_link, link.source))
end
def track_event
diff --git a/doc/api/graphql/index.md b/doc/api/graphql/index.md
index 36f16ff9141..d7e03974881 100644
--- a/doc/api/graphql/index.md
+++ b/doc/api/graphql/index.md
@@ -73,10 +73,18 @@ To avoid having a breaking change affect your integrations, you should
familiarize yourself with the [deprecation and removal process](#deprecation-and-removal-process) and
frequently [verify your API calls against the future breaking-change schema](#verify-against-the-future-breaking-change-schema).
-Fields behind a feature flag and disabled by default do not follow the deprecation and removal process, and can be removed at any time without notice.
-
For more information, see [Deprecating GitLab features](../../development/deprecation_guidelines/index.md).
+### Breaking change exemptions
+
+Schema items
+[marked as alpha](../../development/api_graphql_styleguide.md#mark-schema-items-as-alpha)
+are exempt from the deprecation process, and can be removed or changed at any
+time without notice.
+
+Fields behind a feature flag and disabled by default do not follow the
+deprecation and removal process, and can be removed at any time without notice.
+
WARNING:
GitLab makes all attempts to follow the [deprecation and removal process](#deprecation-and-removal-process).
On rare occasions, GitLab might make immediate breaking changes to the GraphQL
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index ebd053bdb5a..5649b65c598 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -1264,7 +1264,6 @@ Input type: `AiActionInput`
| <a id="mutationaiactionresolvevulnerability"></a>`resolveVulnerability` | [`AiResolveVulnerabilityInput`](#airesolvevulnerabilityinput) | Input for resolve_vulnerability AI action. |
| <a id="mutationaiactionsummarizecomments"></a>`summarizeComments` | [`AiSummarizeCommentsInput`](#aisummarizecommentsinput) | Input for summarize_comments AI action. |
| <a id="mutationaiactionsummarizereview"></a>`summarizeReview` | [`AiSummarizeReviewInput`](#aisummarizereviewinput) | Input for summarize_review AI action. |
-| <a id="mutationaiactiontanukibot"></a>`tanukiBot` | [`AiTanukiBotInput`](#aitanukibotinput) | Input for tanuki_bot AI action. |
#### Fields
@@ -32438,15 +32437,6 @@ see the associated mutation type above.
| ---- | ---- | ----------- |
| <a id="aisummarizereviewinputresourceid"></a>`resourceId` | [`AiModelID!`](#aimodelid) | Global ID of the resource to mutate. |
-### `AiTanukiBotInput`
-
-#### Arguments
-
-| Name | Type | Description |
-| ---- | ---- | ----------- |
-| <a id="aitanukibotinputquestion"></a>`question` | [`String!`](#string) | GitLab documentation question for AI to answer. |
-| <a id="aitanukibotinputresourceid"></a>`resourceId` | [`AiModelID!`](#aimodelid) | Global ID of the resource to mutate. |
-
### `AlertManagementPayloadAlertFieldInput`
Field that are available while modifying the custom mapping attributes for an HTTP integration.
diff --git a/doc/ci/testing/code_quality.md b/doc/ci/testing/code_quality.md
index 6b4275d8055..93b6ea7c352 100644
--- a/doc/ci/testing/code_quality.md
+++ b/doc/ci/testing/code_quality.md
@@ -51,7 +51,9 @@ Code Quality results are shown in the:
Code Quality analysis results display in the merge request widget area if a report from the target
branch is available for comparison. The merge request widget displays Code Quality findings and resolutions that
-were introduced by the changes made in the merge request.
+were introduced by the changes made in the merge request. Multiple Code Quality findings with identical
+fingerprints display as a single entry in the merge request widget, each individual finding is available in the
+full report available in the **Pipeline** details view.
![Code Quality Widget](img/code_quality_widget_13_11.png)
diff --git a/doc/ci/testing/unit_test_reports.md b/doc/ci/testing/unit_test_reports.md
index b37dd54e96b..2954b759166 100644
--- a/doc/ci/testing/unit_test_reports.md
+++ b/doc/ci/testing/unit_test_reports.md
@@ -92,7 +92,11 @@ To copy the name of a single failed test:
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/235525) in Test Reports in GitLab 13.9.
If a test failed in the project's default branch in the last 14 days, a message like
-`Failed {n} time(s) in {default_branch} in the last 14 days` is displayed for that test.
+`Failed {n} time(s) in {default_branch} in the last 14 days` is displayed for that test.
+
+The calculation includes failed tests in completed pipelines, but not [blocked pipelines](../jobs/job_control.md#types-of-manual-jobs).
+[Issue 431265](https://gitlab.com/gitlab-org/gitlab/-/issues/431265) proposes to
+also include blocked pipelines in the calculation.
## How to set it up
diff --git a/doc/ci/yaml/index.md b/doc/ci/yaml/index.md
index 9b781ca6d13..9f9bf4f3a9f 100644
--- a/doc/ci/yaml/index.md
+++ b/doc/ci/yaml/index.md
@@ -2529,13 +2529,22 @@ job2:
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/32022) in GitLab 12.3.
-Use `interruptible` if a job should be canceled when a newer pipeline starts before the job completes.
+Use `interruptible` to configure the [auto-cancel redundant pipelines](../pipelines/settings.md#auto-cancel-redundant-pipelines)
+feature to cancel a job before it completes if a new pipeline on the same ref starts for a newer commit. If the feature
+is disabled, the keyword has no effect.
-This keyword has no effect if [automatic cancellation of redundant pipelines](../pipelines/settings.md#auto-cancel-redundant-pipelines)
-is disabled. When enabled, a running job with `interruptible: true` is cancelled when
-starting a pipeline for a new change on the same branch.
+**Running** jobs are only cancelled when the jobs are configured with `interruptible: true` and:
-You can't cancel subsequent jobs after a job with `interruptible: false` starts.
+- No jobs configured with `interruptible: false` have started at any time.
+ After a job with `interruptible: false` starts, the entire pipeline is no longer
+ considered interruptible.
+ - If the pipeline triggered a downstream pipeline, but no job with `interruptible: false`
+ in the downstream pipeline has started yet, the downstream pipeline is also cancelled.
+- The new pipeline is for a commit with new changes. The **Auto-cancel redundant pipelines**
+ feature has no effect if you select **Run pipeline** in the UI to run a pipeline for the same commit.
+
+A job that has not started yet is always considered `interruptible: true`, regardless of the job's configuration.
+The `interruptible` configuration is only considered after the job starts.
**Keyword type**: Job keyword. You can use it only as part of a job or in the
[`default` section](#default).
@@ -2579,11 +2588,10 @@ In this example, a new pipeline causes a running pipeline to be:
- Only set `interruptible: true` if the job can be safely canceled after it has started,
like a build job. Deployment jobs usually shouldn't be cancelled, to prevent partial deployments.
-- To completely cancel a running pipeline, all jobs must have `interruptible: true`,
- or `interruptible: false` jobs must not have started.
-- Running jobs are only cancelled if the newer pipeline has new changes.
- For example, a running job is not be cancelled if you run a new pipeline for the same
- commit by selecting **Run pipeline** in the UI.
+- You can add an optional manual job with `interruptible: false` in the first stage of
+ a pipeline to allow users to manually prevent a pipeline from being automatically
+ cancelled. After a user starts the job, the pipeline cannot be canceled by the
+ **Auto-cancel redundant pipelines** feature.
### `needs`
@@ -4670,6 +4678,13 @@ child3:
yaml_variables: false
```
+**Additional details**:
+
+- CI/CD variables forwarded to downstream pipelines with `trigger:forward` have the
+ [highest precedence](../variables/index.md#cicd-variable-precedence). If a variable
+ with the same name is defined in the downstream pipeline, that variable is overwritten
+ by the forwarded variable.
+
### `variables`
Use `variables` to define [CI/CD variables](../variables/index.md#define-a-cicd-variable-in-the-gitlab-ciyml-file) for jobs.
diff --git a/doc/development/api_graphql_styleguide.md b/doc/development/api_graphql_styleguide.md
index 73c52dda859..0cc1730cb5b 100644
--- a/doc/development/api_graphql_styleguide.md
+++ b/doc/development/api_graphql_styleguide.md
@@ -174,8 +174,8 @@ See the [deprecating schema items](#deprecating-schema-items) section for how to
### Breaking change exemptions
-Schema items [marked as alpha](#mark-schema-items-as-alpha) are exempt from the deprecation process,
-and can be removed or changed at any time without notice.
+See the
+[GraphQL API breaking change exemptions documentation](../api/graphql/index.md#breaking-change-exemptions).
## Global IDs
@@ -806,9 +806,10 @@ The documentation mentions that the old Global ID style is now deprecated.
You can mark GraphQL schema items (fields, arguments, enum values, and mutations) as
[Alpha](../policy/experiment-beta-support.md#experiment).
-An item marked as Alpha is [exempt from the deprecation process](#breaking-change-exemptions) and can be removed
-at any time without notice. Mark an item as Alpha when it is
-subject to change and not ready for public use.
+An item marked as Alpha is
+[exempt from the deprecation process](../api/graphql/index.md#breaking-change-exemptions) and can be
+removed at any time without notice. Mark an item as Alpha when it is subject to
+change and not ready for public use.
NOTE:
Only mark new items as Alpha. Never mark existing items
diff --git a/doc/user/application_security/policies/scan-result-policies.md b/doc/user/application_security/policies/scan-result-policies.md
index cf800e6061a..91502fb202e 100644
--- a/doc/user/application_security/policies/scan-result-policies.md
+++ b/doc/user/application_security/policies/scan-result-policies.md
@@ -84,7 +84,7 @@ If you're not familiar with how to read [JSON schemas](https://json-schema.org/)
the following sections and tables provide an alternative.
| Field | Type | Required | Possible values | Description |
-|----------------------|-------------------------------|----------|-----------------|------------------------------------------|
+|----------------------|-------------------------------|----------|-----------------|-------------------------------------------|
| `scan_result_policy` | `array` of Scan Result Policy | true | | List of scan result policies (maximum 5). |
## Scan result policy schema
@@ -95,14 +95,14 @@ FLAG:
On self-managed GitLab, by default the `approval_settings` field is available. To hide the feature, an administrator can [disable the feature flag](../../../administration/feature_flags.md) named `scan_result_any_merge_request`.
On GitLab.com, this feature is available. See the `approval_settings` section below for more information.
-| Field | Type | Required |Possible values | Description |
-|--------|------|----------|----------------|-------------|
-| `name` | `string` | true | | Name of the policy. Maximum of 255 characters.|
-| `description` | `string` | false | | Description of the policy. |
-| `enabled` | `boolean` | true | `true`, `false` | Flag to enable (`true`) or disable (`false`) the policy. |
-| `rules` | `array` of rules | true | | List of rules that the policy applies. |
-| `actions` | `array` of actions | false | | List of actions that the policy enforces. |
-| `approval_settings` | `object` | false | | Project settings that the policy overrides. |
+| Field | Type | Required | Possible values | Description |
+|---------------------|--------------------|----------|-----------------|----------------------------------------------------------|
+| `name` | `string` | true | | Name of the policy. Maximum of 255 characters. |
+| `description` | `string` | false | | Description of the policy. |
+| `enabled` | `boolean` | true | `true`, `false` | Flag to enable (`true`) or disable (`false`) the policy. |
+| `rules` | `array` of rules | true | | List of rules that the policy applies. |
+| `actions` | `array` of actions | false | | List of actions that the policy enforces. |
+| `approval_settings` | `object` | false | | Project settings that the policy overrides. |
## `scan_finding` rule type
@@ -191,14 +191,14 @@ On self-managed GitLab, by default the `prevent_pushing_and_force_pushing` field
The settings set in the policy overwrite settings in the project.
-| Field | Type | Required | Possible values | Description |
-|-------------------------------------|------|----------|-----------------|-------------|
-| `block_unprotecting_branches` | `boolean` | false | `true`, `false` | Prevent a user from removing a branch from the protected branches list, deleting a protected branch, or changing the default branch if that branch is included in the security policy. |
-| `prevent_approval_by_author` | `boolean` | false | `true`, `false` | When enabled, two person approval is required on all MRs as merge request authors cannot approve their own MRs and merge them unilaterally. |
-| `prevent_approval_by_commit_author` | `boolean` | false | `true`, `false` | When enabled, users who have contributed code to the MR are ineligible for approval, ensuring code committers cannot introduce vulnerabilities and approve code to merge. |
-| `remove_approvals_with_new_commit` | `boolean` | false | `true`, `false` | If an MR receives all necessary approvals to merge, but then a new commit is added, new approvals are required. This ensures new commits that may include vulnerabilities cannot be introduced. |
-| `require_password_to_approve` | `boolean` | false | `true`, `false` | Password confirmation on approvals provides an additional level of security. Enabling this enforces the setting on all projects targeted by this policy. |
-| `prevent_pushing_and_force_pushing` | `boolean` | false | `true`, `false` | Prevent pushing and force pushing to a protected branch. |
+| Field | Type | Required | Possible values | Applicable rule type | Description |
+|-------------------------------------|-----------|----------|-----------------|----------------------|-------------|
+| `block_unprotecting_branches` | `boolean` | false | `true`, `false` | All | When enabled, prevents a user from removing a branch from the protected branches list, deleting a protected branch, or changing the default branch if that branch is included in the security policy. This ensures users cannot remove protection status from a branch to merge vulnerable code. |
+| `prevent_approval_by_author` | `boolean` | false | `true`, `false` | `Any merge request` | When enabled, merge request authors cannot approve their own MRs. This ensures code authors cannot introduce vulnerabilities and approve code to merge. |
+| `prevent_approval_by_commit_author` | `boolean` | false | `true`, `false` | `Any merge request` | When enabled, users who have contributed code to the MR are ineligible for approval. This ensures code committers cannot introduce vulnerabilities and approve code to merge. |
+| `remove_approvals_with_new_commit` | `boolean` | false | `true`, `false` | `Any merge request` | When enabled, if an MR receives all necessary approvals to merge, but then a new commit is added, new approvals are required. This ensures new commits that may include vulnerabilities cannot be introduced. |
+| `require_password_to_approve` | `boolean` | false | `true`, `false` | `Any merge request` | When enabled, there will be password confirmation on approvals. Password confirmation adds an extra layer of security. |
+| `prevent_pushing_and_force_pushing` | `boolean` | false | `true`, `false` | All | When enabled, prevents users from pushing and force pushing to a protected branch. This ensures users do not bypass the merge request process to add vulnerable code to a branch. |
## Example security scan result policies project
diff --git a/doc/user/application_security/vulnerabilities/index.md b/doc/user/application_security/vulnerabilities/index.md
index 476b2411621..68e7a183f30 100644
--- a/doc/user/application_security/vulnerabilities/index.md
+++ b/doc/user/application_security/vulnerabilities/index.md
@@ -282,7 +282,7 @@ To manually apply the patch that GitLab generated for a vulnerability:
> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/6176) in GitLab 14.9.
NOTE:
-Security training is not accessible in an environment that is offline, meaning computers that are isolated from the public internet as a security measure. Some third-party training vendors may require you to sign up for a _free_ account. Sign up for an account by going to
+Security training is not accessible in an environment that is offline, meaning computers that are isolated from the public internet as a security measure. Specifically, the GitLab server needs the ability to query the API endpoints for any training provider you choose to enable. Some third-party training vendors may require you to sign up for a _free_ account. Sign up for an account by going to
any of [Secure Code Warrior](https://www.securecodewarrior.com/), [Kontra](https://application.security/), or [SecureFlag](https://www.secureflag.com/).
GitLab does not send any user information to these third-party vendors; we do send the CWE or OWASP identifier and the language name of the file extension.
diff --git a/doc/user/group/import/index.md b/doc/user/group/import/index.md
index 32d59cbb322..ae86ce30399 100644
--- a/doc/user/group/import/index.md
+++ b/doc/user/group/import/index.md
@@ -251,7 +251,7 @@ To view group import history:
To review the results of an import:
1. Go to the [Group import history page](#group-import-history).
-1. To see the details of a failed import, select the **See failures** link on any import with a **Failed** status.
+1. To see the details of a failed import, select the **See failures** link on any import with a **Failed** or **Partially completed** status.
### Migrated group items
diff --git a/doc/user/permissions.md b/doc/user/permissions.md
index 38854d3daef..0ca54de71f6 100644
--- a/doc/user/permissions.md
+++ b/doc/user/permissions.md
@@ -110,7 +110,7 @@ The following table lists project permissions available for each role:
| [Issues](project/issues/index.md):<br>View [confidential issues](project/issues/confidential_issues.md) | (2) | ✓ | ✓ | ✓ | ✓ |
| [Issues](project/issues/index.md):<br>Close / reopen (18) | | ✓ | ✓ | ✓ | ✓ |
| [Issues](project/issues/index.md):<br>Lock threads | | ✓ | ✓ | ✓ | ✓ |
-| [Issues](project/issues/index.md):<br>Manage [related issues](project/issues/related_issues.md) | | ✓ | ✓ | ✓ | ✓ |
+| [Issues](project/issues/index.md):<br>Manage [related issues](project/issues/related_issues.md) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [Issues](project/issues/index.md):<br>Manage tracker | | ✓ | ✓ | ✓ | ✓ |
| [Issues](project/issues/index.md):<br>Move issues (14) | | ✓ | ✓ | ✓ | ✓ |
| [Issues](project/issues/index.md):<br>Set issue [time tracking](project/time_tracking.md) estimate and time spent | | ✓ | ✓ | ✓ | ✓ |
diff --git a/doc/user/project/issues/related_issues.md b/doc/user/project/issues/related_issues.md
index 10c7899ac30..2f405aaa810 100644
--- a/doc/user/project/issues/related_issues.md
+++ b/doc/user/project/issues/related_issues.md
@@ -25,7 +25,7 @@ To manage linked issues through our API, see [Issue links API](../../../api/issu
Prerequisites:
-- You must have at least the Reporter role for both projects.
+- You must have at least the Guest role for the source issue's project, and you must be able to access the target issue.
To link one issue to another:
diff --git a/lib/gitlab/application_rate_limiter.rb b/lib/gitlab/application_rate_limiter.rb
index 469927b8a53..47dab17adda 100644
--- a/lib/gitlab/application_rate_limiter.rb
+++ b/lib/gitlab/application_rate_limiter.rb
@@ -52,8 +52,8 @@ module Gitlab
project_testing_integration: { threshold: 5, interval: 1.minute },
email_verification: { threshold: 10, interval: 10.minutes },
email_verification_code_send: { threshold: 10, interval: 1.hour },
- phone_verification_send_code: { threshold: 10, interval: 1.hour },
- phone_verification_verify_code: { threshold: 10, interval: 10.minutes },
+ phone_verification_send_code: { threshold: 10, interval: 1.day },
+ phone_verification_verify_code: { threshold: 10, interval: 1.day },
namespace_exists: { threshold: 20, interval: 1.minute },
update_namespace_name: { threshold: -> { application_settings.update_namespace_name_rate_limit }, interval: 1.hour },
fetch_google_ip_list: { threshold: 10, interval: 1.minute },
diff --git a/lib/gitlab/bitbucket_server_import/importers/pull_requests_importer.rb b/lib/gitlab/bitbucket_server_import/importers/pull_requests_importer.rb
index ae73681f7f8..ce9f18feaa8 100644
--- a/lib/gitlab/bitbucket_server_import/importers/pull_requests_importer.rb
+++ b/lib/gitlab/bitbucket_server_import/importers/pull_requests_importer.rb
@@ -24,7 +24,15 @@ module Gitlab
next if already_processed?(pull_request)
next unless pull_request.merged? || pull_request.closed?
- [pull_request.source_branch_sha, pull_request.target_branch_sha]
+ [].tap do |commits|
+ source_sha = pull_request.source_branch_sha
+ target_sha = pull_request.target_branch_sha
+
+ existing_commits = repo.commits_by(oids: [source_sha, target_sha]).map(&:sha)
+
+ commits << source_branch_commit(source_sha, pull_request) unless existing_commits.include?(source_sha)
+ commits << target_branch_commit(target_sha) unless existing_commits.include?(target_sha)
+ end
end.flatten
# Bitbucket Server keeps tracks of references for open pull requests in
@@ -78,6 +86,22 @@ module Gitlab
def id_for_already_processed_cache(object)
object.iid
end
+
+ def repo
+ @repo ||= project.repository
+ end
+
+ def ref_path(pull_request)
+ "refs/#{Repository::REF_MERGE_REQUEST}/#{pull_request.iid}/head"
+ end
+
+ def source_branch_commit(source_branch_sha, pull_request)
+ [source_branch_sha, ':', ref_path(pull_request)].join
+ end
+
+ def target_branch_commit(target_branch_sha)
+ [target_branch_sha, ':refs/keep-around/', target_branch_sha].join
+ end
end
end
end
diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb
index 59813e4f5a0..2f83f96d051 100644
--- a/lib/gitlab/gon_helper.rb
+++ b/lib/gitlab/gon_helper.rb
@@ -77,6 +77,7 @@ module Gitlab
push_frontend_feature_flag(:security_auto_fix)
push_frontend_feature_flag(:source_editor_toolbar)
push_frontend_feature_flag(:vscode_web_ide, current_user)
+ push_frontend_feature_flag(:frecent_namespaces_suggestions, current_user)
# To be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/399248
push_frontend_feature_flag(:remove_monitor_metrics)
push_frontend_feature_flag(:custom_emoji)
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index c7dfb517b45..5434f6169ab 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -14049,6 +14049,9 @@ msgstr ""
msgid "Couldn't link epics. You must have at least the Guest role in the epic's group."
msgstr ""
+msgid "Couldn't link issues. You must have at least the Guest role in the source issue's project."
+msgstr ""
+
msgid "Country / Region"
msgstr ""
@@ -26534,8 +26537,8 @@ msgstr ""
msgid "IssuesAnalytics|Sorry, your filter produced no results"
msgstr ""
-msgid "IssuesAnalytics|This month (%{chartDateRange})"
-msgid_plural "IssuesAnalytics|Last %{monthsCount} months (%{chartDateRange})"
+msgid "IssuesAnalytics|This month (%{dateRange})"
+msgid_plural "IssuesAnalytics|Last %{monthsCount} months (%{dateRange})"
msgstr[0] ""
msgstr[1] ""
diff --git a/rubocop/rubocop-code_reuse.yml b/rubocop/rubocop-code_reuse.yml
index 2bd3339368d..6f9d7902dc6 100644
--- a/rubocop/rubocop-code_reuse.yml
+++ b/rubocop/rubocop-code_reuse.yml
@@ -42,5 +42,4 @@ CodeReuse/ActiveRecord:
- ee/db/fixtures/**/*.rb
- ee/lib/tasks/**/*.rake
- ee/lib/ee/gitlab/background_migration/**/*.rb
- - ee/lib/gitlab/llm/open_ai/response_modifiers/tanuki_bot.rb
- ee/lib/gitlab/usage/metrics/instrumentations/**/*.rb
diff --git a/spec/features/issues/related_issues_spec.rb b/spec/features/issues/related_issues_spec.rb
index f460b4b1c7f..b2bb2d1dd6e 100644
--- a/spec/features/issues/related_issues_spec.rb
+++ b/spec/features/issues/related_issues_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe 'Related issues', :js, feature_category: :team_planning do
let_it_be(:project) { create(:project_empty_repo, :public) }
let_it_be(:project_b) { create(:project_empty_repo, :public) }
- let_it_be(:project_unauthorized) { create(:project_empty_repo, :public) }
+ let_it_be(:project_unauthorized) { create(:project_empty_repo) }
let_it_be(:internal_project) { create(:project_empty_repo, :internal) }
let_it_be(:private_project) { create(:project_empty_repo, :private) }
let_it_be(:public_project) { create(:project_empty_repo, :public) }
@@ -90,44 +90,11 @@ RSpec.describe 'Related issues', :js, feature_category: :team_planning do
visit project_issue_path(internal_project, internal_issue)
expect(page).to have_css('.related-issues-block')
- expect(page).not_to have_button 'Add a related issue'
- end
-
- it 'shows widget when private project' do
- private_project.add_guest(user)
-
- visit project_issue_path(private_project, private_issue)
-
- expect(page).to have_css('.related-issues-block')
- expect(page).not_to have_button 'Add a related issue'
- end
-
- it 'shows widget when public project' do
- public_project.add_guest(user)
-
- visit project_issue_path(public_project, public_issue)
-
- expect(page).to have_css('.related-issues-block')
- expect(page).not_to have_button 'Add a related issue'
- end
- end
-
- context 'when logged in and a reporter' do
- before do
- sign_in(user)
- end
-
- it 'shows widget when internal project' do
- internal_project.add_reporter(user)
-
- visit project_issue_path(internal_project, internal_issue)
-
- expect(page).to have_css('.related-issues-block')
expect(page).to have_button 'Add a related issue'
end
it 'shows widget when private project' do
- private_project.add_reporter(user)
+ private_project.add_guest(user)
visit project_issue_path(private_project, private_issue)
@@ -136,7 +103,7 @@ RSpec.describe 'Related issues', :js, feature_category: :team_planning do
end
it 'shows widget when public project' do
- public_project.add_reporter(user)
+ public_project.add_guest(user)
visit project_issue_path(public_project, public_issue)
@@ -146,7 +113,7 @@ RSpec.describe 'Related issues', :js, feature_category: :team_planning do
it 'shows widget on their own public issue' do
issue = create :issue, project: public_project, author: user
- public_project.add_reporter(user)
+ public_project.add_guest(user)
visit project_issue_path(public_project, issue)
diff --git a/spec/frontend/super_sidebar/components/global_search/components/frequent_groups_spec.js b/spec/frontend/super_sidebar/components/global_search/components/frequent_groups_spec.js
index e63768a03c0..5f41aff3ab3 100644
--- a/spec/frontend/super_sidebar/components/global_search/components/frequent_groups_spec.js
+++ b/spec/frontend/super_sidebar/components/global_search/components/frequent_groups_spec.js
@@ -1,16 +1,37 @@
import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import FrequentItems from '~/super_sidebar/components/global_search/components/frequent_items.vue';
import FrequentGroups from '~/super_sidebar/components/global_search/components/frequent_groups.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import currentUserFrecentGroupsQuery from '~/super_sidebar/graphql/queries/current_user_frecent_groups.query.graphql';
+import waitForPromises from 'helpers/wait_for_promises';
+import { frecentGroupsMock } from '../../../mock_data';
+
+Vue.use(VueApollo);
describe('FrequentlyVisitedGroups', () => {
let wrapper;
const groupsPath = '/mock/group/path';
+ const currentUserFrecentGroupsQueryHandler = jest.fn().mockResolvedValue({
+ data: {
+ frecentGroups: frecentGroupsMock,
+ },
+ });
+
+ const createComponent = (options, frecentNamespacesSuggestionsEnabled = true) => {
+ const mockApollo = createMockApollo([
+ [currentUserFrecentGroupsQuery, currentUserFrecentGroupsQueryHandler],
+ ]);
- const createComponent = (options) => {
wrapper = shallowMount(FrequentGroups, {
+ apolloProvider: mockApollo,
provide: {
groupsPath,
+ glFeatures: {
+ frecentNamespacesSuggestions: frecentNamespacesSuggestionsEnabled,
+ },
},
...options,
});
@@ -36,6 +57,21 @@ describe('FrequentlyVisitedGroups', () => {
});
});
+ it('loads frecent groups', () => {
+ createComponent();
+
+ expect(currentUserFrecentGroupsQueryHandler).toHaveBeenCalled();
+ expect(findFrequentItems().props('loading')).toBe(true);
+ });
+
+ it('passes fetched groups to FrequentItems', async () => {
+ createComponent();
+ await waitForPromises();
+
+ expect(findFrequentItems().props('items')).toEqual(frecentGroupsMock);
+ expect(findFrequentItems().props('loading')).toBe(false);
+ });
+
it('with a user, passes a storage key string to FrequentItems', () => {
gon.current_username = 'test_user';
createComponent();
@@ -60,4 +96,15 @@ describe('FrequentlyVisitedGroups', () => {
expect(spy).toHaveBeenCalledTimes(1);
});
+
+ describe('when the frecentNamespacesSuggestions feature flag is disabled', () => {
+ beforeEach(() => {
+ createComponent({}, false);
+ });
+
+ it('does not fetch frecent groups', () => {
+ expect(currentUserFrecentGroupsQueryHandler).not.toHaveBeenCalled();
+ expect(findFrequentItems().props('loading')).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/super_sidebar/components/global_search/components/frequent_item_spec.js b/spec/frontend/super_sidebar/components/global_search/components/frequent_item_spec.js
index aae1fc543f9..95abcb625e1 100644
--- a/spec/frontend/super_sidebar/components/global_search/components/frequent_item_spec.js
+++ b/spec/frontend/super_sidebar/components/global_search/components/frequent_item_spec.js
@@ -14,11 +14,16 @@ describe('FrequentlyVisitedItem', () => {
avatar: '/mock/avatar.png',
};
- const createComponent = () => {
+ const createComponent = (frecentNamespacesSuggestionsEnabled = false) => {
wrapper = shallowMountExtended(FrequentItem, {
propsData: {
item: mockItem,
},
+ provide: {
+ glFeatures: {
+ frecentNamespacesSuggestions: frecentNamespacesSuggestionsEnabled,
+ },
+ },
stubs: {
GlButton: stubComponent(GlButton, {
template: '<button type="button" v-on="$listeners"></button>',
@@ -95,4 +100,14 @@ describe('FrequentlyVisitedItem', () => {
expect(keydownSpy.mock.calls[0][0].defaultPrevented).toBe(true);
});
});
+
+ describe('when the frecentNamespacesSuggestionsEnabled feature flag is enabled', () => {
+ beforeEach(() => {
+ createComponent(true);
+ });
+
+ it('does not render the remove button', () => {
+ expect(findRemoveButton().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/super_sidebar/components/global_search/components/frequent_items_spec.js b/spec/frontend/super_sidebar/components/global_search/components/frequent_items_spec.js
index 4700e9c7e10..7b9084a97e3 100644
--- a/spec/frontend/super_sidebar/components/global_search/components/frequent_items_spec.js
+++ b/spec/frontend/super_sidebar/components/global_search/components/frequent_items_spec.js
@@ -2,6 +2,7 @@ import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlIcon } from '@gi
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import GlobalSearchFrequentItems from '~/super_sidebar/components/global_search/components/frequent_items.vue';
import FrequentItem from '~/super_sidebar/components/global_search/components/frequent_item.vue';
+import FrequentItemSkeleton from '~/super_sidebar/components/global_search/components/frequent_item_skeleton.vue';
import { getItemsFromLocalStorage, removeItemFromLocalStorage } from '~/super_sidebar/utils';
import { cachedFrequentProjects } from 'jest/super_sidebar/mock_data';
@@ -29,12 +30,17 @@ describe('FrequentlyVisitedItems', () => {
viewAllItemsPath: '/mock/all_items',
};
- const createComponent = (props) => {
+ const createComponent = (props, frecentNamespacesSuggestionsEnabled = true) => {
wrapper = shallowMountExtended(GlobalSearchFrequentItems, {
propsData: {
...mockProps,
...props,
},
+ provide: {
+ glFeatures: {
+ frecentNamespacesSuggestions: frecentNamespacesSuggestionsEnabled,
+ },
+ },
stubs: {
GlDisclosureDropdownGroup,
},
@@ -42,60 +48,162 @@ describe('FrequentlyVisitedItems', () => {
};
const findItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem);
+ const findSkeleton = () => wrapper.findComponent(FrequentItemSkeleton);
const findItemRenderer = (root) => root.findComponent(FrequentItem);
const setStoredItems = (items) => {
getItemsFromLocalStorage.mockReturnValue(items);
};
- beforeEach(() => {
- setStoredItems(mockStoredItems);
- });
-
- describe('without a storage key', () => {
+ describe('when the frecentNamespacesSuggestions feature flag is disabled', () => {
beforeEach(() => {
- createComponent({ storageKey: null });
+ setStoredItems(mockStoredItems);
});
- it('does not render anything', () => {
- expect(wrapper.html()).toBe('');
+ describe('without a storage key', () => {
+ beforeEach(() => {
+ createComponent({ storageKey: null }, false);
+ });
+
+ it('does not render anything', () => {
+ expect(wrapper.html()).toBe('');
+ });
+
+ it('emits a nothing-to-render event', () => {
+ expect(wrapper.emitted('nothing-to-render')).toEqual([[]]);
+ });
});
- it('emits a nothing-to-render event', () => {
- expect(wrapper.emitted('nothing-to-render')).toEqual([[]]);
+ describe('with a storageKey', () => {
+ beforeEach(() => {
+ createComponent({}, false);
+ });
+
+ describe('common behavior', () => {
+ it('calls getItemsFromLocalStorage', () => {
+ expect(getItemsFromLocalStorage).toHaveBeenCalledWith({
+ storageKey,
+ maxItems: mockProps.maxItems,
+ });
+ });
+
+ it('renders the group name', () => {
+ expect(wrapper.text()).toContain(mockProps.groupName);
+ });
+
+ it('renders the view all items link', () => {
+ const lastItem = findItems().at(-1);
+ expect(lastItem.props('item')).toMatchObject({
+ text: mockProps.viewAllItemsText,
+ href: mockProps.viewAllItemsPath,
+ });
+
+ const icon = lastItem.findComponent(GlIcon);
+ expect(icon.props('name')).toBe(mockProps.viewAllItemsIcon);
+ });
+ });
+
+ describe('with stored items', () => {
+ it('renders the items', () => {
+ const items = findItems();
+
+ mockStoredItems.forEach((storedItem, index) => {
+ const dropdownItem = items.at(index);
+
+ // Check GlDisclosureDropdownItem's item has the right structure
+ expect(dropdownItem.props('item')).toMatchObject({
+ text: storedItem.name,
+ href: storedItem.webUrl,
+ });
+
+ // Check FrequentItem's item has the right structure
+ expect(findItemRenderer(dropdownItem).props('item')).toMatchObject({
+ id: storedItem.id,
+ title: storedItem.name,
+ subtitle: expect.any(String),
+ avatar: storedItem.avatarUrl,
+ });
+ });
+ });
+
+ it('does not render the empty state text', () => {
+ expect(wrapper.text()).not.toContain('mock empty state text');
+ });
+
+ describe('removing an item', () => {
+ let itemToRemove;
+
+ beforeEach(() => {
+ const itemRenderer = findItemRenderer(findItems().at(0));
+ itemToRemove = itemRenderer.props('item');
+ itemRenderer.vm.$emit('remove', itemToRemove);
+ });
+
+ it('calls removeItemFromLocalStorage when an item emits a remove event', () => {
+ expect(removeItemFromLocalStorage).toHaveBeenCalledWith({
+ storageKey,
+ item: itemToRemove,
+ });
+ });
+
+ it('no longer renders that item', () => {
+ const renderedItemTexts = findItems().wrappers.map((item) => item.props('item').text);
+ expect(renderedItemTexts).not.toContain(itemToRemove.text);
+ });
+ });
+ });
});
- });
- describe('with a storageKey', () => {
- beforeEach(() => {
- createComponent();
+ describe('with no stored items', () => {
+ beforeEach(() => {
+ setStoredItems([]);
+ createComponent({}, false);
+ });
+
+ it('renders the empty state text', () => {
+ expect(wrapper.text()).toContain(mockProps.emptyStateText);
+ });
});
+ });
- describe('common behavior', () => {
- it('calls getItemsFromLocalStorage', () => {
- expect(getItemsFromLocalStorage).toHaveBeenCalledWith({
- storageKey,
- maxItems: mockProps.maxItems,
+ describe('when the frecentNamespacesSuggestions feature flag is enabled', () => {
+ describe('while items are being fetched', () => {
+ beforeEach(() => {
+ createComponent({
+ loading: true,
});
});
- it('renders the group name', () => {
- expect(wrapper.text()).toContain(mockProps.groupName);
+ it('shows the loading state', () => {
+ expect(findSkeleton().exists()).toBe(true);
});
- it('renders the view all items link', () => {
- const lastItem = findItems().at(-1);
- expect(lastItem.props('item')).toMatchObject({
- text: mockProps.viewAllItemsText,
- href: mockProps.viewAllItemsPath,
- });
+ it('does not show the empty state', () => {
+ expect(wrapper.text()).not.toContain(mockProps.emptyStateText);
+ });
+ });
+
+ describe('when there are no items', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('does not show the loading state', () => {
+ expect(findSkeleton().exists()).toBe(false);
+ });
- const icon = lastItem.findComponent(GlIcon);
- expect(icon.props('name')).toBe(mockProps.viewAllItemsIcon);
+ it('shows the empty state', () => {
+ expect(wrapper.text()).toContain(mockProps.emptyStateText);
});
});
- describe('with stored items', () => {
+ describe('when there are items', () => {
+ beforeEach(() => {
+ createComponent({
+ items: mockStoredItems,
+ });
+ });
+
it('renders the items', () => {
const items = findItems();
@@ -118,42 +226,13 @@ describe('FrequentlyVisitedItems', () => {
});
});
- it('does not render the empty state text', () => {
- expect(wrapper.text()).not.toContain('mock empty state text');
+ it('does not show the loading state', () => {
+ expect(findSkeleton().exists()).toBe(false);
});
- describe('removing an item', () => {
- let itemToRemove;
-
- beforeEach(() => {
- const itemRenderer = findItemRenderer(findItems().at(0));
- itemToRemove = itemRenderer.props('item');
- itemRenderer.vm.$emit('remove', itemToRemove);
- });
-
- it('calls removeItemFromLocalStorage when an item emits a remove event', () => {
- expect(removeItemFromLocalStorage).toHaveBeenCalledWith({
- storageKey,
- item: itemToRemove,
- });
- });
-
- it('no longer renders that item', () => {
- const renderedItemTexts = findItems().wrappers.map((item) => item.props('item').text);
- expect(renderedItemTexts).not.toContain(itemToRemove.text);
- });
+ it('does not show the empty state', () => {
+ expect(wrapper.text()).not.toContain(mockProps.emptyStateText);
});
});
});
-
- describe('with no stored items', () => {
- beforeEach(() => {
- setStoredItems([]);
- createComponent();
- });
-
- it('renders the empty state text', () => {
- expect(wrapper.text()).toContain(mockProps.emptyStateText);
- });
- });
});
diff --git a/spec/frontend/super_sidebar/components/global_search/components/frequent_projects_spec.js b/spec/frontend/super_sidebar/components/global_search/components/frequent_projects_spec.js
index 7554c123574..40ce8a4b42a 100644
--- a/spec/frontend/super_sidebar/components/global_search/components/frequent_projects_spec.js
+++ b/spec/frontend/super_sidebar/components/global_search/components/frequent_projects_spec.js
@@ -1,16 +1,37 @@
import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import FrequentItems from '~/super_sidebar/components/global_search/components/frequent_items.vue';
import FrequentProjects from '~/super_sidebar/components/global_search/components/frequent_projects.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import currentUserFrecentProjectsQuery from '~/super_sidebar/graphql/queries/current_user_frecent_projects.query.graphql';
+import waitForPromises from 'helpers/wait_for_promises';
+import { frecentProjectsMock } from '../../../mock_data';
+
+Vue.use(VueApollo);
describe('FrequentlyVisitedProjects', () => {
let wrapper;
const projectsPath = '/mock/project/path';
+ const currentUserFrecentProjectsQueryHandler = jest.fn().mockResolvedValue({
+ data: {
+ frecentProjects: frecentProjectsMock,
+ },
+ });
+
+ const createComponent = (options, frecentNamespacesSuggestionsEnabled = true) => {
+ const mockApollo = createMockApollo([
+ [currentUserFrecentProjectsQuery, currentUserFrecentProjectsQueryHandler],
+ ]);
- const createComponent = (options) => {
wrapper = shallowMount(FrequentProjects, {
+ apolloProvider: mockApollo,
provide: {
projectsPath,
+ glFeatures: {
+ frecentNamespacesSuggestions: frecentNamespacesSuggestionsEnabled,
+ },
},
...options,
});
@@ -36,6 +57,21 @@ describe('FrequentlyVisitedProjects', () => {
});
});
+ it('loads frecent projects', () => {
+ createComponent();
+
+ expect(currentUserFrecentProjectsQueryHandler).toHaveBeenCalled();
+ expect(findFrequentItems().props('loading')).toBe(true);
+ });
+
+ it('passes fetched projects to FrequentItems', async () => {
+ createComponent();
+ await waitForPromises();
+
+ expect(findFrequentItems().props('items')).toEqual(frecentProjectsMock);
+ expect(findFrequentItems().props('loading')).toBe(false);
+ });
+
it('with a user, passes a storage key string to FrequentItems', () => {
gon.current_username = 'test_user';
createComponent();
@@ -60,4 +96,15 @@ describe('FrequentlyVisitedProjects', () => {
expect(spy).toHaveBeenCalledTimes(1);
});
+
+ describe('when the frecentNamespacesSuggestions feature flag is disabled', () => {
+ beforeEach(() => {
+ createComponent({}, false);
+ });
+
+ it('does not fetch frecent projects', () => {
+ expect(currentUserFrecentProjectsQueryHandler).not.toHaveBeenCalled();
+ expect(findFrequentItems().props('loading')).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/super_sidebar/mock_data.js b/spec/frontend/super_sidebar/mock_data.js
index d2d2faedbf8..ef0c3686a4e 100644
--- a/spec/frontend/super_sidebar/mock_data.js
+++ b/spec/frontend/super_sidebar/mock_data.js
@@ -188,6 +188,26 @@ export const userMenuMockData = {
canary_toggle_com_url: 'https://next.gitlab.com',
};
+export const frecentGroupsMock = [
+ {
+ id: 'gid://gitlab/Group/1',
+ name: 'Frecent group 1',
+ namespace: 'Frecent Namespace 1',
+ webUrl: '/frecent-namespace-1/frecent-group-1',
+ avatarUrl: '/uploads/-/avatar1.png',
+ },
+];
+
+export const frecentProjectsMock = [
+ {
+ id: 'gid://gitlab/Project/1',
+ name: 'Frecent project 1',
+ namespace: 'Frecent Namespace 1 / Frecent project 1',
+ webUrl: '/frecent-namespace-1/frecent-project-1',
+ avatarUrl: '/uploads/-/avatar1.png',
+ },
+];
+
export const cachedFrequentProjects = JSON.stringify([
{
id: 1,
diff --git a/spec/lib/gitlab/bitbucket_server_import/importers/pull_requests_importer_spec.rb b/spec/lib/gitlab/bitbucket_server_import/importers/pull_requests_importer_spec.rb
index af8a0202083..2dd6c7fd6dc 100644
--- a/spec/lib/gitlab/bitbucket_server_import/importers/pull_requests_importer_spec.rb
+++ b/spec/lib/gitlab/bitbucket_server_import/importers/pull_requests_importer_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe Gitlab::BitbucketServerImport::Importers::PullRequestsImporter, feature_category: :importers do
+ include RepoHelpers
+
let_it_be(:project) do
create(:project, :with_import_url, :import_started, :empty_repo,
import_data_attributes: {
@@ -15,6 +17,8 @@ RSpec.describe Gitlab::BitbucketServerImport::Importers::PullRequestsImporter, f
subject(:importer) { described_class.new(project) }
describe '#execute', :clean_gitlab_redis_cache do
+ let(:commit_sha) { 'aaaa1' }
+
before do
allow_next_instance_of(BitbucketServer::Client) do |client|
allow(client).to receive(:pull_requests).and_return(
@@ -23,7 +27,7 @@ RSpec.describe Gitlab::BitbucketServerImport::Importers::PullRequestsImporter, f
{
'id' => 1,
'state' => 'MERGED',
- 'fromRef' => { 'latestCommit' => 'aaaa1' },
+ 'fromRef' => { 'latestCommit' => commit_sha },
'toRef' => { 'latestCommit' => 'aaaa2' }
}
),
@@ -77,15 +81,42 @@ RSpec.describe Gitlab::BitbucketServerImport::Importers::PullRequestsImporter, f
context 'when pull requests are in merged or declined status' do
it 'fetches latest commits from the remote repository' do
+ expected_refmap = [
+ "#{commit_sha}:refs/merge-requests/1/head",
+ 'aaaa2:refs/keep-around/aaaa2',
+ 'bbbb1:refs/merge-requests/2/head',
+ 'bbbb2:refs/keep-around/bbbb2'
+ ]
+
expect(project.repository).to receive(:fetch_remote).with(
project.import_url,
- refmap: %w[aaaa1 aaaa2 bbbb1 bbbb2],
+ refmap: expected_refmap,
prune: false
)
importer.execute
end
+ context 'when a commit already exists' do
+ let_it_be(:commit_sha) { create_file_in_repo(project, 'master', 'master', 'test.txt', 'testing')[:result] }
+
+ it 'does not fetch the commit' do
+ expected_refmap = [
+ 'aaaa2:refs/keep-around/aaaa2',
+ 'bbbb1:refs/merge-requests/2/head',
+ 'bbbb2:refs/keep-around/bbbb2'
+ ]
+
+ expect(project.repository).to receive(:fetch_remote).with(
+ project.import_url,
+ refmap: expected_refmap,
+ prune: false
+ )
+
+ importer.execute
+ end
+ end
+
context 'when feature flag "fetch_commits_for_bitbucket_server" is disabled' do
before do
stub_feature_flags(fetch_commits_for_bitbucket_server: false)
diff --git a/spec/policies/issue_policy_spec.rb b/spec/policies/issue_policy_spec.rb
index c19b7bcf9ea..fb7a056ee48 100644
--- a/spec/policies/issue_policy_spec.rb
+++ b/spec/policies/issue_policy_spec.rb
@@ -91,7 +91,7 @@ RSpec.describe IssuePolicy, feature_category: :team_planning do
end
it 'allows guests to read issues' do
- expect(permissions(guest, issue)).to be_allowed(:read_issue, :read_issue_iid, :admin_issue_relation)
+ expect(permissions(guest, issue)).to be_allowed(:read_issue, :read_issue_iid, :admin_issue_relation, :admin_issue_link, :create_issue_link)
expect(permissions(guest, issue)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality, :mark_note_as_internal)
expect(permissions(guest, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :admin_issue_relation)
@@ -146,13 +146,27 @@ RSpec.describe IssuePolicy, feature_category: :team_planning do
let(:confidential_issue_no_assignee) { create(:issue, :confidential, project: project) }
it 'does not allow non-members to read confidential issues' do
- expect(permissions(non_member, confidential_issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :admin_issue_relation)
- expect(permissions(non_member, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality, :admin_issue_relation)
+ expect(permissions(non_member, confidential_issue)).to be_disallowed(
+ :read_issue, :read_issue_iid, :update_issue, :admin_issue,
+ :admin_issue_relation, :admin_issue_link, :create_issue_link
+ )
+ expect(permissions(non_member, confidential_issue_no_assignee)).to be_disallowed(
+ :read_issue, :read_issue_iid, :update_issue, :admin_issue,
+ :set_issue_metadata, :set_confidentiality, :admin_issue_relation,
+ :admin_issue_link, :create_issue_link
+ )
end
it 'does not allow guests to read confidential issues' do
- expect(permissions(guest, confidential_issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :admin_issue_relation)
- expect(permissions(guest, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality, :admin_issue_relation)
+ expect(permissions(guest, confidential_issue)).to be_disallowed(
+ :read_issue, :read_issue_iid, :update_issue, :admin_issue,
+ :admin_issue_relation, :admin_issue_link, :create_issue_link
+ )
+ expect(permissions(guest, confidential_issue_no_assignee)).to be_disallowed(
+ :read_issue, :read_issue_iid, :update_issue, :admin_issue,
+ :set_issue_metadata, :set_confidentiality, :admin_issue_relation,
+ :admin_issue_link, :create_issue_link
+ )
end
it 'allows reporters to read, update, and admin confidential issues' do
@@ -212,16 +226,25 @@ RSpec.describe IssuePolicy, feature_category: :team_planning do
create(:project_group_link, group: group, project: project)
end
- it 'allows guests to award emoji' do
- expect(permissions(guest, issue)).to be_allowed(:award_emoji)
- end
-
it 'does not allow anonymous user to create todos' do
expect(permissions(nil, issue)).to be_allowed(:read_issue)
expect(permissions(nil, issue)).to be_disallowed(:create_todo, :update_subscription, :set_issue_metadata, :set_confidentiality, :admin_issue_relation)
expect(permissions(nil, new_issue)).to be_disallowed(:create_issue, :set_issue_metadata, :set_confidentiality)
end
+ it 'does not allow anonymous user to create issue links' do
+ expect(permissions(nil, issue)).to be_disallowed(:create_issue_link)
+ end
+
+ it 'allows guests to award emoji' do
+ expect(permissions(guest, issue)).to be_allowed(:award_emoji)
+ expect(permissions(guest, issue_no_assignee)).to be_allowed(:award_emoji)
+ end
+
+ it 'allows guests to create and admin issue links' do
+ expect(permissions(guest, issue)).to be_allowed(:create_issue_link, :admin_issue_link)
+ end
+
it 'allows guests to read issues' do
expect(permissions(guest, issue)).to be_allowed(:read_issue, :read_issue_iid, :create_todo, :update_subscription, :admin_issue_relation)
expect(permissions(guest, issue)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality)
@@ -296,6 +319,14 @@ RSpec.describe IssuePolicy, feature_category: :team_planning do
expect(permissions(non_member, new_issue)).to be_allowed(:set_confidentiality)
end
+ it 'allows non-members to create issue links' do
+ expect(permissions(non_member, issue)).to be_allowed(:create_issue_link)
+ end
+
+ it 'does not allow non-members to admin issue links' do
+ expect(permissions(non_member, issue)).to be_disallowed(:admin_issue_link)
+ end
+
it 'allows support_bot to read issues' do
# support_bot is still allowed read access in public projects through :public_access permission,
# see project_policy public_access rules policy (rule { can?(:public_access) }.policy {...})
@@ -387,6 +418,10 @@ RSpec.describe IssuePolicy, feature_category: :team_planning do
expect(permissions(guest, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
end
+ it 'does not allow guests to create or admin issue links' do
+ expect(permissions(guest, confidential_issue)).to be_disallowed(:create_issue_link, :admin_issue_link)
+ end
+
it 'allows reporters to read, update, and admin confidential issues' do
expect(permissions(reporter, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :admin_issue_relation)
expect(permissions(reporter, confidential_issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
@@ -397,6 +432,10 @@ RSpec.describe IssuePolicy, feature_category: :team_planning do
expect(permissions(reporter_from_group_link, confidential_issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
end
+ it 'allows reporters to create and admin issue links' do
+ expect(permissions(reporter, confidential_issue)).to be_allowed(:create_issue_link, :admin_issue_link)
+ end
+
it 'allows issue authors to read and update their confidential issues' do
expect(permissions(author, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue_relation)
expect(permissions(author, confidential_issue)).to be_disallowed(:admin_issue, :set_issue_metadata, :set_confidentiality)
diff --git a/spec/requests/api/issue_links_spec.rb b/spec/requests/api/issue_links_spec.rb
index fcb199a91a4..f06b3168b67 100644
--- a/spec/requests/api/issue_links_spec.rb
+++ b/spec/requests/api/issue_links_spec.rb
@@ -86,17 +86,16 @@ RSpec.describe API::IssueLinks, feature_category: :team_planning do
end
end
- context 'when user does not have write access to given issue' do
+ context 'when user does not have access to given issue' do
it 'returns 403' do
unauthorized_project = create(:project)
target_issue = create(:issue, project: unauthorized_project)
- unauthorized_project.add_guest(user)
post api("/projects/#{project.id}/issues/#{issue.iid}/links", user),
params: { target_project_id: unauthorized_project.id, target_issue_iid: target_issue.iid }
- expect(response).to have_gitlab_http_status(:forbidden)
- expect(json_response['message']).to eq("Couldn't link issue. You must have at least the Reporter role in both projects.")
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq("404 Project Not Found")
end
end
@@ -273,12 +272,11 @@ RSpec.describe API::IssueLinks, feature_category: :team_planning do
unauthorized_project = create(:project)
target_issue = create(:issue, project: unauthorized_project)
issue_link = create(:issue_link, source: issue, target: target_issue)
- unauthorized_project.add_guest(user)
delete api("/projects/#{project.id}/issues/#{issue.iid}/links/#{issue_link.id}", user)
expect(response).to have_gitlab_http_status(:not_found)
- expect(json_response['message']).to eq('No Issue Link found')
+ expect(json_response['message']).to eq('404 Project Not Found')
end
end
diff --git a/spec/requests/projects/issue_links_controller_spec.rb b/spec/requests/projects/issue_links_controller_spec.rb
index ea73b733285..bd53ef583a3 100644
--- a/spec/requests/projects/issue_links_controller_spec.rb
+++ b/spec/requests/projects/issue_links_controller_spec.rb
@@ -44,15 +44,15 @@ RSpec.describe Projects::IssueLinksController, feature_category: :team_planning
let(:issue_b) { create :issue, project: project }
before do
- project.add_role(user, user_role)
login_as user
end
context 'with success' do
- let(:user_role) { :developer }
+ let(:user_role) { :guest }
let(:issuable_references) { [issue_b.to_reference] }
it 'returns success JSON' do
+ project.add_role(user, user_role)
post namespace_project_issue_links_path(issue_links_params(issuable_references: issuable_references))
list_service_response = IssueLinks::ListService.new(issue, user).execute
@@ -67,18 +67,19 @@ RSpec.describe Projects::IssueLinksController, feature_category: :team_planning
let(:user_role) { :guest }
let(:issuable_references) { [issue_b.to_reference] }
- it 'returns 403' do
+ it 'returns 404' do
post namespace_project_issue_links_path(issue_links_params(issuable_references: issuable_references))
- expect(response).to have_gitlab_http_status(:forbidden)
+ expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when failing service result' do
- let(:user_role) { :developer }
+ let(:user_role) { :guest }
let(:issuable_references) { ["##{non_existing_record_iid}"] }
it 'returns failure JSON' do
+ project.add_role(user, user_role)
post namespace_project_issue_links_path(issue_links_params(issuable_references: issuable_references))
list_service_response = IssueLinks::ListService.new(issue, user).execute
@@ -94,29 +95,30 @@ RSpec.describe Projects::IssueLinksController, feature_category: :team_planning
let(:issue_link) { create :issue_link, source: issue, target: referenced_issue }
before do
- project.add_role(user, user_role)
+ project.add_role(user, user_role) if defined?(user_role)
login_as user
end
+ subject(:delete_issue_link) { delete namespace_project_issue_link_path(issue_links_params(id: issue_link.id)) }
+
context 'when unauthorized' do
context 'when no authorization on current project' do
let(:referenced_issue) { create :issue, project: project }
- let(:user_role) { :guest }
- it 'returns 403' do
- delete namespace_project_issue_link_path(issue_links_params(id: issue_link.id))
+ it 'returns 404' do
+ delete_issue_link
- expect(response).to have_gitlab_http_status(:forbidden)
+ expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when no authorization on the related issue project' do
# unauthorized project issue
let(:referenced_issue) { create :issue }
- let(:user_role) { :developer }
+ let(:user_role) { :guest }
it 'returns 404' do
- delete namespace_project_issue_link_path(issue_links_params(id: issue_link.id))
+ delete_issue_link
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -125,10 +127,10 @@ RSpec.describe Projects::IssueLinksController, feature_category: :team_planning
context 'when authorized' do
let(:referenced_issue) { create :issue, project: project }
- let(:user_role) { :developer }
+ let(:user_role) { :guest }
it 'returns success JSON' do
- delete namespace_project_issue_link_path(issue_links_params(id: issue_link.id))
+ delete_issue_link
list_service_response = IssueLinks::ListService.new(issue, user).execute
@@ -136,26 +138,22 @@ RSpec.describe Projects::IssueLinksController, feature_category: :team_planning
end
end
- context 'when non of issues of the link is not the issue requested in the path' do
+ context 'when none of issues of the link is not the issue requested in the path' do
let(:referenced_issue) { create(:issue, project: project) }
let(:another_issue) { create(:issue, project: project) }
let(:issue) { create(:issue, project: project) }
- let(:user_role) { :developer }
+ let(:user_role) { :guest }
let!(:issue_link) { create :issue_link, source: another_issue, target: referenced_issue }
- subject do
- delete namespace_project_issue_link_path(issue_links_params(id: issue_link.id))
- end
-
it 'returns 404' do
- subject
+ delete_issue_link
expect(response).to have_gitlab_http_status(:not_found)
end
it 'does not delete the link' do
- expect { subject }.not_to change { IssueLink.count }.from(1)
+ expect { delete_issue_link }.not_to change { IssueLink.count }.from(1)
end
end
end
diff --git a/spec/services/issue_links/create_service_spec.rb b/spec/services/issue_links/create_service_spec.rb
index 71603da1c90..6705383f735 100644
--- a/spec/services/issue_links/create_service_spec.rb
+++ b/spec/services/issue_links/create_service_spec.rb
@@ -23,7 +23,6 @@ RSpec.describe IssueLinks::CreateService, feature_category: :team_planning do
before do
project.add_developer(user)
- restricted_issuable.project.add_guest(user)
another_project.add_developer(user)
end
diff --git a/spec/services/issue_links/list_service_spec.rb b/spec/services/issue_links/list_service_spec.rb
index b5cc8c4dcdc..af78c95abc0 100644
--- a/spec/services/issue_links/list_service_spec.rb
+++ b/spec/services/issue_links/list_service_spec.rb
@@ -6,10 +6,10 @@ RSpec.describe IssueLinks::ListService, feature_category: :team_planning do
let(:user) { create :user }
let(:project) { create(:project_empty_repo, :private) }
let(:issue) { create :issue, project: project }
- let(:user_role) { :developer }
+ let(:user_role) { :guest }
before do
- project.add_role(user, user_role)
+ project.add_role(user, user_role) if user_role
end
describe '#execute' do
@@ -161,26 +161,23 @@ RSpec.describe IssueLinks::ListService, feature_category: :team_planning do
end
context 'user can admin related issues just on target project' do
- let(:user_role) { :guest }
+ let(:user_role) { nil }
let(:target_project) { create :project }
let(:referenced_issue) { create :issue, project: target_project }
it 'returns no destroy relation path' do
- target_project.add_developer(user)
+ target_project.add_guest(user)
expect(subject.first[:relation_path]).to be_nil
end
end
context 'user can admin related issues just on source project' do
- let(:user_role) { :developer }
let(:target_project) { create :project }
let(:referenced_issue) { create :issue, project: target_project }
it 'returns no destroy relation path' do
- target_project.add_guest(user)
-
- expect(subject.first[:relation_path]).to be_nil
+ expect(subject).to eq([])
end
end
diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb
index 7cd2cd8f564..83e3af5b588 100644
--- a/spec/services/issues/create_service_spec.rb
+++ b/spec/services/issues/create_service_spec.rb
@@ -825,7 +825,8 @@ RSpec.describe Issues::CreateService, feature_category: :team_planning do
end
context 'add related issue' do
- let_it_be(:related_issue) { create(:issue, project: project) }
+ let_it_be(:private_project) { create(:project) }
+ let_it_be(:related_issue) { create(:issue, project: private_project) }
let(:opts) do
{ title: 'A new issue', add_related_issue: related_issue }
@@ -839,7 +840,7 @@ RSpec.describe Issues::CreateService, feature_category: :team_planning do
context 'when user has access to the related issue' do
before do
- project.add_developer(user)
+ private_project.add_guest(user)
end
it 'adds a link to the issue' do
diff --git a/spec/support/shared_contexts/policies/project_policy_shared_context.rb b/spec/support/shared_contexts/policies/project_policy_shared_context.rb
index 68eb3539813..0ef1e041ec6 100644
--- a/spec/support/shared_contexts/policies/project_policy_shared_context.rb
+++ b/spec/support/shared_contexts/policies/project_policy_shared_context.rb
@@ -32,7 +32,7 @@ RSpec.shared_context 'ProjectPolicy context' do
let(:base_reporter_permissions) do
%i[
- admin_issue admin_issue_link admin_label admin_milestone admin_issue_board_list
+ admin_issue admin_label admin_milestone admin_issue_board_list
create_snippet create_incident daily_statistics create_merge_request_in download_code
download_wiki_code fork_project metrics_dashboard read_build
read_commit_status read_confidential_issues read_container_image
diff --git a/spec/support/shared_examples/policies/project_policy_shared_examples.rb b/spec/support/shared_examples/policies/project_policy_shared_examples.rb
index 349e15aa79d..a79e3d179bb 100644
--- a/spec/support/shared_examples/policies/project_policy_shared_examples.rb
+++ b/spec/support/shared_examples/policies/project_policy_shared_examples.rb
@@ -57,7 +57,7 @@ RSpec.shared_examples 'project policies as anonymous' do
context 'when a project has pending invites' do
let(:group) { create(:group, :public) }
let(:project) { create(:project, :public, namespace: group) }
- let(:user_permissions) { [:create_merge_request_in, :create_project, :create_issue, :create_note, :upload_file, :award_emoji, :create_incident] }
+ let(:user_permissions) { [:create_merge_request_in, :create_project, :create_issue, :create_note, :upload_file, :award_emoji, :create_incident, :admin_issue_link] }
let(:anonymous_permissions) { base_guest_permissions - user_permissions }
let(:current_user) { anonymous }
diff --git a/spec/support/shared_examples/services/issuable_links/create_links_shared_examples.rb b/spec/support/shared_examples/services/issuable_links/create_links_shared_examples.rb
index 10dc185157c..a2897044676 100644
--- a/spec/support/shared_examples/services/issuable_links/create_links_shared_examples.rb
+++ b/spec/support/shared_examples/services/issuable_links/create_links_shared_examples.rb
@@ -5,14 +5,6 @@ RSpec.shared_examples 'issuable link creation' do |use_references: true|
let(:response_keys) { [:status, :created_references] }
let(:async_notes) { false }
let(:already_assigned_error_msg) { "#{issuable_type.capitalize}(s) already assigned" }
- let(:permission_error_status) { issuable_type == :issue ? 403 : 404 }
- let(:permission_error_msg) do
- if issuable_type == :issue
- "Couldn't link issue. You must have at least the Reporter role in both projects."
- else
- no_found_error_msg
- end
- end
let(:no_found_error_msg) do
"No matching #{issuable_type} found. Make sure that you are adding a valid #{issuable_type} URL."
@@ -47,7 +39,7 @@ RSpec.shared_examples 'issuable link creation' do |use_references: true|
let(:params) { set_params([restricted_issuable]) }
it 'returns error' do
- is_expected.to eq(message: permission_error_msg, status: :error, http_status: permission_error_status)
+ is_expected.to eq(message: no_found_error_msg, status: :error, http_status: 404)
end
it 'no relationship is created' do