diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-11-15 06:11:59 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-11-15 06:11:59 +0300 |
commit | 29c6745feab7edf3c0485b34b5e5fffdded9c402 (patch) | |
tree | 0953ef194cd5a2cd4ad750804ffe135539b46f6b | |
parent | ff45b1aea7367cf98a5b70e8324e4826c119bf87 (diff) |
Add latest changes from gitlab-org/gitlab@master
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 |