diff --git a/Gemfile b/Gemfile index 728eff6814a..7a0b17a065f 100644 --- a/Gemfile +++ b/Gemfile @@ -89,7 +89,7 @@ gem 'coverband', '6.1.4', require: false, feature_category: :shared gem 'devise', '~> 4.9.3', feature_category: :system_access gem 'devise-pbkdf2-encryptable', '~> 0.0.0', path: 'vendor/gems/devise-pbkdf2-encryptable' # rubocop:todo Gemfile/MissingFeatureCategory gem 'bcrypt', '~> 3.1', '>= 3.1.14' # rubocop:todo Gemfile/MissingFeatureCategory -gem 'doorkeeper', '~> 5.8', '>= 5.8.0', feature_category: :system_access +gem 'doorkeeper', '~> 5.8', '>= 5.8.1', feature_category: :system_access gem 'doorkeeper-openid_connect', '~> 1.8.10', feature_category: :system_access gem 'doorkeeper-device_authorization_grant', '~> 1.0.0', feature_category: :system_access gem 'rexml', '~> 3.3.2' # rubocop:todo Gemfile/MissingFeatureCategory diff --git a/Gemfile.checksum b/Gemfile.checksum index f1f9b2b1841..eccbb04ecbc 100644 --- a/Gemfile.checksum +++ b/Gemfile.checksum @@ -128,7 +128,7 @@ {"name":"discordrb-webhooks","version":"3.5.0","platform":"ruby","checksum":"52fba8bce3b08059d4a41a1e73a9a152958e788a9330275450126b44f01c23b1"}, {"name":"docile","version":"1.4.0","platform":"ruby","checksum":"5f1734bde23721245c20c3d723e76c104208e1aa01277a69901ce770f0ebb8d3"}, {"name":"domain_name","version":"0.5.20190701","platform":"ruby","checksum":"000a600454cb4a344769b2f10b531765ea7bd3a304fe47ed12e5ca1eab969851"}, -{"name":"doorkeeper","version":"5.8.0","platform":"ruby","checksum":"5abc747ee0eaa3140e165b41e937941f05d5ce5bcd8b8ed6718e6711623a8105"}, +{"name":"doorkeeper","version":"5.8.1","platform":"ruby","checksum":"6d54f3c36755d8cfcb7e4f04fbcf1ff3492c816090ad78126ec8a722c292d26c"}, {"name":"doorkeeper-device_authorization_grant","version":"1.0.3","platform":"ruby","checksum":"94c3ac12a0d50942850ecd58ed64298b397a5e903e8880cb68d4085600932679"}, {"name":"doorkeeper-openid_connect","version":"1.8.10","platform":"ruby","checksum":"b28efaa9b52cbe9aca4efc825a7ef63cc950ae6a96f4a72ed8362f278a453742"}, {"name":"dotenv","version":"2.7.6","platform":"ruby","checksum":"2451ed5e8e43776d7a787e51d6f8903b98e446146c7ad143d5678cc2c409d547"}, diff --git a/Gemfile.lock b/Gemfile.lock index 1acd6304219..0cd7efb6999 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -535,7 +535,7 @@ GEM docile (1.4.0) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) - doorkeeper (5.8.0) + doorkeeper (5.8.1) railties (>= 5) doorkeeper-device_authorization_grant (1.0.3) doorkeeper (~> 5.5) @@ -2032,7 +2032,7 @@ DEPENDENCIES diff_match_patch (~> 0.1.0)! diffy (~> 3.4) discordrb-webhooks (~> 3.5) - doorkeeper (~> 5.8, >= 5.8.0) + doorkeeper (~> 5.8, >= 5.8.1) doorkeeper-device_authorization_grant (~> 1.0.0) doorkeeper-openid_connect (~> 1.8.10) duo_api (~> 1.3) diff --git a/Gemfile.next.checksum b/Gemfile.next.checksum index 447031bce2d..5befb50059e 100644 --- a/Gemfile.next.checksum +++ b/Gemfile.next.checksum @@ -128,7 +128,7 @@ {"name":"discordrb-webhooks","version":"3.5.0","platform":"ruby","checksum":"52fba8bce3b08059d4a41a1e73a9a152958e788a9330275450126b44f01c23b1"}, {"name":"docile","version":"1.4.0","platform":"ruby","checksum":"5f1734bde23721245c20c3d723e76c104208e1aa01277a69901ce770f0ebb8d3"}, {"name":"domain_name","version":"0.5.20190701","platform":"ruby","checksum":"000a600454cb4a344769b2f10b531765ea7bd3a304fe47ed12e5ca1eab969851"}, -{"name":"doorkeeper","version":"5.8.0","platform":"ruby","checksum":"5abc747ee0eaa3140e165b41e937941f05d5ce5bcd8b8ed6718e6711623a8105"}, +{"name":"doorkeeper","version":"5.8.1","platform":"ruby","checksum":"6d54f3c36755d8cfcb7e4f04fbcf1ff3492c816090ad78126ec8a722c292d26c"}, {"name":"doorkeeper-device_authorization_grant","version":"1.0.3","platform":"ruby","checksum":"94c3ac12a0d50942850ecd58ed64298b397a5e903e8880cb68d4085600932679"}, {"name":"doorkeeper-openid_connect","version":"1.8.10","platform":"ruby","checksum":"b28efaa9b52cbe9aca4efc825a7ef63cc950ae6a96f4a72ed8362f278a453742"}, {"name":"dotenv","version":"2.7.6","platform":"ruby","checksum":"2451ed5e8e43776d7a787e51d6f8903b98e446146c7ad143d5678cc2c409d547"}, diff --git a/Gemfile.next.lock b/Gemfile.next.lock index 8510fb956b0..34aa559688f 100644 --- a/Gemfile.next.lock +++ b/Gemfile.next.lock @@ -544,7 +544,7 @@ GEM docile (1.4.0) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) - doorkeeper (5.8.0) + doorkeeper (5.8.1) railties (>= 5) doorkeeper-device_authorization_grant (1.0.3) doorkeeper (~> 5.5) @@ -2060,7 +2060,7 @@ DEPENDENCIES diff_match_patch (~> 0.1.0)! diffy (~> 3.4) discordrb-webhooks (~> 3.5) - doorkeeper (~> 5.8, >= 5.8.0) + doorkeeper (~> 5.8, >= 5.8.1) doorkeeper-device_authorization_grant (~> 1.0.0) doorkeeper-openid_connect (~> 1.8.10) duo_api (~> 1.3) diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index 07c837a1fd2..fb662957006 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -17,7 +17,7 @@ import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import { keysFor, ISSUABLE_EDIT_DESCRIPTION } from '~/behaviors/shortcuts/keybindings'; import { shouldDisableShortcuts } from '~/behaviors/shortcuts/shortcuts_toggle'; import { sanitize } from '~/lib/dompurify'; -import { STATUS_CLOSED, TYPE_ISSUE, issuableTypeText } from '~/issues/constants'; +import { STATUS_CLOSED, TYPE_ISSUE, TYPE_INCIDENT, issuableTypeText } from '~/issues/constants'; import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { isLoggedIn } from '~/lib/utils/common_utils'; @@ -35,6 +35,7 @@ import issuesEventHub from '../event_hub'; import promoteToEpicMutation from '../queries/promote_to_epic.mutation.graphql'; import updateIssueMutation from '../queries/update_issue.mutation.graphql'; import DeleteIssueModal from './delete_issue_modal.vue'; +import HeaderActionsConfidentialityToggle from './header_actions_confidentiality_toggle.vue'; const trackingMixin = Tracking.mixin({ label: 'delete_issue' }); @@ -70,6 +71,7 @@ export default { AbuseCategorySelector, SidebarSubscriptionsWidget, IssuableLockForm, + HeaderActionsConfidentialityToggle, }, directives: { GlModal: GlModalDirective, @@ -216,6 +218,9 @@ export default { }, }; }, + showConfidentialityToggle() { + return [TYPE_ISSUE, TYPE_INCIDENT].includes(this.issueType) && this.canUpdateIssue; + }, }, created() { eventHub.$on('toggle.issuable.state', this.toggleIssueState); @@ -378,6 +383,10 @@ export default { + + +import { GlDisclosureDropdownItem } from '@gitlab/ui'; + +import { createAlert } from '~/alert'; +import { s__, __, sprintf } from '~/locale'; + +import { confidentialityQueries } from '~/sidebar/queries/constants'; +import { issuableTypeText } from '~/issues/constants'; + +export default { + i18n: { + enableConfidentiality: s__('WorkItem|Turn on confidentiality'), + confidentialityEnabled: s__('WorkItem|Confidentiality turned on.'), + disableConfidentiality: s__('WorkItem|Turn off confidentiality'), + confidentialityDisabled: s__('WorkItem|Confidentiality turned off.'), + }, + components: { + GlDisclosureDropdownItem, + }, + inject: ['iid', 'issueType', 'projectPath', 'fullPath'], + data() { + return { + confidential: false, + }; + }, + apollo: { + confidential: { + query() { + return confidentialityQueries[this.issueType].query; + }, + variables() { + return { + fullPath: this.fullPath, + iid: String(this.iid), + }; + }, + update(data) { + return data.workspace?.issuable?.confidential || false; + }, + skip() { + return !this.iid; + }, + error() { + createAlert({ + message: sprintf( + __('Something went wrong while getting %{issuableType} confidentiality status.'), + { + issuableType: this.issueTypeText, + }, + ), + }); + }, + }, + }, + computed: { + issueTypeText() { + const { issueType } = this; + + return issuableTypeText[issueType] ?? issueType; + }, + confidentialItem() { + return { + text: this.confidential + ? this.$options.i18n.disableConfidentiality + : this.$options.i18n.enableConfidentiality, + }; + }, + confidentialityText() { + return this.confidential + ? this.$options.i18n.confidentialityEnabled + : this.$options.i18n.confidentialityDisabled; + }, + }, + methods: { + handleToggleWorkItemConfidentiality() { + this.$apollo + .mutate({ + mutation: confidentialityQueries[this.issueType].mutation, + variables: { + input: { + iid: String(this.iid), + projectPath: this.projectPath, + confidential: !this.confidential, + }, + }, + }) + .then( + ({ + data: { + issuableSetConfidential: { errors }, + }, + }) => { + if (errors.length) { + createAlert({ + message: errors[0], + }); + } else { + this.$emit('closeActionsDropdown'); + this.$toast.show(this.confidentialityText); + } + }, + ) + .catch(() => { + createAlert({ + message: sprintf( + __('Something went wrong while setting %{issuableType} confidentiality.'), + { + issuableType: this.issueTypeText, + }, + ), + }); + }); + }, + }, +}; + + + diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 41efd6670a4..3c4edb3fd38 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -16,7 +16,6 @@ import Translate from '~/vue_shared/translate'; import UserSelect from '~/vue_shared/components/user_select/user_select.vue'; import CollapsedAssigneeList from './components/assignees/collapsed_assignee_list.vue'; import SidebarAssigneesWidget from './components/assignees/sidebar_assignees_widget.vue'; -import SidebarConfidentialityWidget from './components/confidential/sidebar_confidentiality_widget.vue'; import CopyEmailToClipboard from './components/copy/copy_email_to_clipboard.vue'; import SidebarDueDateWidget from './components/date/sidebar_date_widget.vue'; import SidebarEscalationStatus from './components/incidents/sidebar_escalation_status.vue'; @@ -327,39 +326,6 @@ export function mountSidebarLabelsWidget() { }); } -function mountSidebarConfidentialityWidget() { - const el = document.querySelector('.js-sidebar-confidential-widget-root'); - - if (!el) { - return null; - } - - const { fullPath, iid } = getSidebarOptions(); - const dataNode = document.getElementById('js-confidential-issue-data'); - const initialData = JSON.parse(dataNode.innerHTML); - - return new Vue({ - el, - name: 'SidebarConfidentialityWidgetRoot', - apolloProvider, - provide: { - canUpdate: initialData.is_editable, - isClassicSidebar: true, - }, - render: (createElement) => - createElement(SidebarConfidentialityWidget, { - props: { - iid: String(iid), - fullPath, - issuableType: - isInIssuePage() || isInIncidentPage() || isInDesignPage() - ? TYPE_ISSUE - : TYPE_MERGE_REQUEST, - }, - }), - }); -} - function mountSidebarDueDateWidget() { const el = document.querySelector('.js-sidebar-due-date-widget-root'); @@ -762,7 +728,6 @@ export function mountSidebar(mediator, store) { mountSidebarCrmContacts(); mountSidebarLabelsWidget(); mountSidebarMilestoneWidget(); - mountSidebarConfidentialityWidget(); mountSidebarDueDateWidget(); mountSidebarReferenceWidget(); mountIssuableLockForm(store); diff --git a/app/assets/javascripts/sidebar/queries/constants.js b/app/assets/javascripts/sidebar/queries/constants.js index b39339b5fd1..5b64f1e73f2 100644 --- a/app/assets/javascripts/sidebar/queries/constants.js +++ b/app/assets/javascripts/sidebar/queries/constants.js @@ -5,6 +5,7 @@ import issuableDatesUpdatedSubscription from '~/graphql_shared/subscriptions/wor import { TYPE_ALERT, TYPE_EPIC, + TYPE_INCIDENT, TYPE_ISSUE, TYPE_MERGE_REQUEST, TYPE_TEST_CASE, @@ -115,6 +116,10 @@ export const userSearchQueries = { }; export const confidentialityQueries = { + [TYPE_INCIDENT]: { + query: issueConfidentialQuery, + mutation: updateIssueConfidentialMutation, + }, [TYPE_ISSUE]: { query: issueConfidentialQuery, mutation: updateIssueConfidentialMutation, diff --git a/app/assets/javascripts/vue_shared/components/projects_list/formatter.js b/app/assets/javascripts/vue_shared/components/projects_list/formatter.js index 97f3a6b31ef..0dd6f4bbb13 100644 --- a/app/assets/javascripts/vue_shared/components/projects_list/formatter.js +++ b/app/assets/javascripts/vue_shared/components/projects_list/formatter.js @@ -1,5 +1,5 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { availableGraphQLProjectActions } from '~/vue_shared/components/projects_list/utils'; +import { availableGraphQLProjectActions } from 'ee_else_ce/vue_shared/components/projects_list/utils'; export const formatGraphQLProjects = (projects) => projects.map( diff --git a/app/assets/javascripts/vue_shared/components/projects_list/project_list_item_actions.vue b/app/assets/javascripts/vue_shared/components/projects_list/project_list_item_actions.vue new file mode 100644 index 00000000000..771bae0493c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/projects_list/project_list_item_actions.vue @@ -0,0 +1,38 @@ + + + diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue index eaeb46c2f94..4b03d5b6f14 100644 --- a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue +++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue @@ -15,6 +15,7 @@ import { deleteParams, } from 'ee_else_ce/vue_shared/components/resource_lists/utils'; import ProjectListItemDescription from 'ee_else_ce/vue_shared/components/projects_list/project_list_item_description.vue'; +import ProjectListItemActions from 'ee_else_ce/vue_shared/components/projects_list/project_list_item_actions.vue'; import ProjectListItemInactiveBadge from 'ee_else_ce/vue_shared/components/projects_list/project_list_item_inactive_badge.vue'; import { VISIBILITY_TYPE_ICON, PROJECT_VISIBILITY_TYPE } from '~/visibility_level/constants'; import { ACCESS_LEVEL_LABELS, ACCESS_LEVEL_NO_ACCESS_INTEGER } from '~/access_level/constants'; @@ -23,8 +24,7 @@ import { __, s__ } from '~/locale'; import { numberToMetricPrefix } from '~/lib/utils/number_utils'; import { truncate } from '~/lib/utils/text_utility'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import ListActions from '~/vue_shared/components/list_actions/list_actions.vue'; -import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants'; +import { ACTION_DELETE } from '~/vue_shared/components/list_actions/constants'; import DeleteModal from '~/projects/components/shared/delete_modal.vue'; import { TIMESTAMP_TYPE_CREATED_AT, @@ -47,7 +47,6 @@ export default { moreTopics: __('More topics'), [TIMESTAMP_TYPE_CREATED_AT]: __('Created'), [TIMESTAMP_TYPE_UPDATED_AT]: __('Updated'), - actions: __('Actions'), project: __('Project'), deleteErrorMessage: s__( 'Projects|An error occurred deleting the project. Please refresh the page to try again.', @@ -63,8 +62,8 @@ export default { GlSprintf, TimeAgoTooltip, DeleteModal, - ListActions, ProjectListItemDescription, + ProjectListItemActions, ProjectListItemInactiveBadge, ProjectListItemDelayedDeletionModalFooter: () => import( @@ -207,16 +206,6 @@ export default { return numberToMetricPrefix(this.project.openIssuesCount); }, - actions() { - return { - [ACTION_EDIT]: { - href: this.project.editPath, - }, - [ACTION_DELETE]: { - action: this.onActionDelete, - }, - }; - }, hasActions() { return this.project.availableActions?.length; }, @@ -407,11 +396,11 @@ export default { -
- +
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index cd09f4682ae..7b0bfb538ec 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -144,6 +144,10 @@ module Types description: 'Number of upvotes for the merge request.', resolver: Resolvers::UpVotesCountResolver + field :resolvable_discussions_count, GraphQL::Types::Int, null: true, + description: 'Number of user discussions that are resolvable in the merge request.' + field :resolved_discussions_count, GraphQL::Types::Int, null: true, + description: 'Number of user discussions that are resolved in the merge request.' field :user_discussions_count, GraphQL::Types::Int, null: true, description: 'Number of user discussions in the merge request.', resolver: Resolvers::UserDiscussionsCountResolver @@ -300,30 +304,6 @@ module Types markdown_field :title_html, null: true markdown_field :description_html, null: true - def user_notes_count - BatchLoader::GraphQL.for(object.id).batch(key: :merge_request_user_notes_count) do |ids, loader, args| - counts = Note.count_for_collection(ids, 'MergeRequest').index_by(&:noteable_id) - - ids.each do |id| - loader.call(id, counts[id]&.count || 0) - end - end - end - - def user_discussions_count - BatchLoader::GraphQL.for(object.id).batch(key: :merge_request_user_discussions_count) do |ids, loader, args| - counts = Note.count_for_collection( - ids, - 'MergeRequest', - 'COUNT(DISTINCT discussion_id) as count' - ).index_by(&:noteable_id) - - ids.each do |id| - loader.call(id, counts[id]&.count || 0) - end - end - end - def diff_stats(path: nil) stats = Array.wrap(object.diff_stats&.to_a) @@ -396,6 +376,29 @@ module Types def web_path ::Gitlab::Routing.url_helpers.project_merge_request_path(object.project, object) end + + def resolvable_discussions_count + notes_count_for_collection(:merge_request_resolvable_discussions_count, &:resolvable) + end + + def resolved_discussions_count + notes_count_for_collection(:merge_request_resolved_discussions_count, &:resolved) + end + + def notes_count_for_collection(key) + BatchLoader::GraphQL.for(object.id).batch(key: key) do |ids, loader, args| + counts = Note.count_for_collection( + ids, + 'MergeRequest', + 'COUNT(DISTINCT discussion_id) as count' + ) + counts = yield(counts).index_by(&:noteable_id) + + ids.each do |id| + loader.call(id, counts[id]&.count || 0) + end + end + end end end diff --git a/app/models/ci/job_token/authorization.rb b/app/models/ci/job_token/authorization.rb index 11093921c5b..458ef6330fa 100644 --- a/app/models/ci/job_token/authorization.rb +++ b/app/models/ci/job_token/authorization.rb @@ -10,6 +10,7 @@ module Ci module JobToken class Authorization < Ci::ApplicationRecord extend Gitlab::InternalEventsTracking + include EachBatch self.table_name = 'ci_job_token_authorizations' diff --git a/app/models/ci/job_token/authorizations_compactor.rb b/app/models/ci/job_token/authorizations_compactor.rb new file mode 100644 index 00000000000..df24293922b --- /dev/null +++ b/app/models/ci/job_token/authorizations_compactor.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Ci + module JobToken + class AuthorizationsCompactor + attr_reader :allowlist_groups, :allowlist_projects + + UnexpectedCompactionEntry = Class.new(StandardError) + RedundantCompactionEntry = Class.new(StandardError) + + def initialize(project_id) + @project_id = project_id + @allowlist_groups = [] + @allowlist_projects = [] + end + + def origin_project_traversal_ids + @origin_project_traversal_ids ||= begin + origin_project_traversal_ids = [] + origin_project_id_batches = [] + + # Collecting id batches to avoid cross-database transactions. + Ci::JobToken::Authorization.where( + accessed_project_id: @project_id + ).each_batch(column: :origin_project_id) do |batch| + origin_project_id_batches << batch.pluck(:origin_project_id) # rubocop:disable Database/AvoidUsingPluckWithoutLimit -- pluck limited by batch size + end + + origin_project_id_batches.each do |batch| + projects = Project.where(id: batch) + origin_project_traversal_ids += projects.map { |p| p.project_namespace.traversal_ids } + end + + origin_project_traversal_ids + end + end + + def compact(limit) + compacted_traversal_ids = Gitlab::Utils::TraversalIdCompactor.compact(origin_project_traversal_ids, limit) + + Gitlab::Utils::TraversalIdCompactor.validate!(origin_project_traversal_ids, compacted_traversal_ids) + + namespace_ids = compacted_traversal_ids.map(&:last) + namespaces = Namespace.where(id: namespace_ids) + + namespaces.each do |namespace| + if namespace.project_namespace? + @allowlist_projects << namespace.project + else + @allowlist_groups << namespace + end + end + end + end + end +end diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 0bafaa49639..9e6610f1448 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -73,7 +73,14 @@ - if issuable_sidebar.has_key?(:confidential) -# haml-lint:disable InlineJavaScript %script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: issuable_sidebar[:confidential], is_editable: can_edit_issuable }.to_json.html_safe - .js-sidebar-confidential-widget-root + + -# REMOVE BLOCK - cf. https://gitlab.com/gitlab-org/gitlab/-/merge_requests/172577 + .block + .hide-collapsed.gl-flex.gl-items-center.gl-font-bold.gl-leading-20.gl-text-default + = _('Confidentiality') + .hide-collapsed.gl-text-subtle + = _("Confidentiality controls have moved to the issue actions menu (%{icon}) at the top of the page.").html_safe % { icon: sprite_icon('ellipsis_v', size: 12, css_class: 'gl-align-middle') } + -# END REMOVE BLOCK = render_if_exists 'shared/issuable/sidebar_cve_id_request', issuable_sidebar: issuable_sidebar diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index a7747181ac9..62f9306143f 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -27414,6 +27414,8 @@ Defines which user roles, users, or groups can merge into a protected branch. | `projectId` | [`Int!`](#int) | ID of the merge request project. | | `rebaseCommitSha` | [`String`](#string) | Rebase commit SHA of the merge request. | | `rebaseInProgress` | [`Boolean!`](#boolean) | Indicates if there is a rebase currently in progress for the merge request. | +| `resolvableDiscussionsCount` | [`Int`](#int) | Number of user discussions that are resolvable in the merge request. | +| `resolvedDiscussionsCount` | [`Int`](#int) | Number of user discussions that are resolved in the merge request. | | `retargeted` | [`Boolean`](#boolean) | Indicates if merge request was retargeted. | | `reviewers` | [`MergeRequestReviewerConnection`](#mergerequestreviewerconnection) | Users from whom a review has been requested. (see [Connections](#connections)) | | `securityAutoFix` **{warning-solid}** | [`Boolean`](#boolean) | **Deprecated** in GitLab 16.11. Security Auto Fix experiment feature was removed. It was always hidden behind `security_auto_fix` feature flag. | diff --git a/doc/user/project/issues/confidential_issues.md b/doc/user/project/issues/confidential_issues.md index 09d9d5945c8..5b03a852d12 100644 --- a/doc/user/project/issues/confidential_issues.md +++ b/doc/user/project/issues/confidential_issues.md @@ -52,8 +52,7 @@ To change the confidentiality of an existing issue: 1. On the left sidebar, select **Search or go to** and find your project. 1. Select **Plan > Issues**. 1. Select the title of your issue to view it. -1. On the right sidebar, next to **Confidentiality**, select **Edit**. -1. Select **Turn on** (or **Turn off** to make the issue non-confidential). +1. In the upper-right corner, select **Issue actions** (**{ellipsis_v}**) and then **Turn on confidentiality** (or **Turn off confidentiality** to make the issue non-confidential). Alternatively, you can use the `/confidential` [quick action](../quick_actions.md#issues-merge-requests-and-epics). diff --git a/doc/user/project/milestones/index.md b/doc/user/project/milestones/index.md index 8792d347800..8e42f94be57 100644 --- a/doc/user/project/milestones/index.md +++ b/doc/user/project/milestones/index.md @@ -279,7 +279,7 @@ of the below topic into "Special milestone filters" --> #### "Upcoming" and "Started" filters (deprecated) WARNING: -This feature was [deprecated](https://issue-link) in GitLab 17.7 +This feature was [deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/501294) in GitLab 17.7 and is [planned for change](https://gitlab.com/gitlab-org/gitlab/-/issues/429728) in 18.0. The behavior of "Upcoming" and "Started" special filters is planned to change in upcoming GitLab major release 18.0. diff --git a/lib/container_registry/gitlab_api_client.rb b/lib/container_registry/gitlab_api_client.rb index 986367aa99c..a63ae822e9c 100644 --- a/lib/container_registry/gitlab_api_client.rb +++ b/lib/container_registry/gitlab_api_client.rb @@ -253,3 +253,5 @@ module ContainerRegistry end end end + +ContainerRegistry::GitlabApiClient.prepend_mod diff --git a/lib/gitlab/legacy_github_import/user_formatter.rb b/lib/gitlab/legacy_github_import/user_formatter.rb index 2f4d107b8e0..6bc70b13b6f 100644 --- a/lib/gitlab/legacy_github_import/user_formatter.rb +++ b/lib/gitlab/legacy_github_import/user_formatter.rb @@ -33,8 +33,8 @@ module Gitlab return if !project.import_data.user_mapping_enabled? || ghost_user? source_user_mapper.find_or_create_source_user( - source_name: gitea_user[:login], - source_username: gitea_user[:full_name] || gitea_user[:login], + source_name: gitea_user[:full_name].presence || gitea_user[:login], + source_username: gitea_user[:login], source_user_identifier: raw[:id] ) end diff --git a/lib/gitlab/utils/traversal_id_compactor.rb b/lib/gitlab/utils/traversal_id_compactor.rb new file mode 100644 index 00000000000..c256ec401ae --- /dev/null +++ b/lib/gitlab/utils/traversal_id_compactor.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +module Gitlab + module Utils + class TraversalIdCompactor + CompactionLimitCannotBeAchievedError = Class.new(StandardError) + RedundantCompactionEntry = Class.new(StandardError) + UnexpectedCompactionEntry = Class.new(StandardError) + + class << self + # This class compacts an array of traversal_ids by finding the most common namespace + # and consolidating all children into an entry for that namespace. It continues this process + # until the size of the final array is less than the limit. If it cannot achieve the limit + # it raises a CompactionLimitCannotBeAchievedError. + # + # The traversal_ids input will look like the array below where each element in the sub-arrays + # is a namespace id. + # + # [ + # [1, 21], + # [1, 2, 3], + # [1, 2, 4], + # [1, 2, 5], + # [1, 2, 12, 13], + # [1, 6, 7], + # [1, 6, 8], + # [9, 10, 11] + # ] + # + # The limit input is the maximum number of elements in the final array. + + # The compact method calls the compact_once method until the size of the final array is less + # than the limit. It then returns the compacted list of traversal_ids + # If it cannot achieve the limit it raises a CompactionLimitCannotBeAchievedError. + + def compact(traversal_ids, limit) + traversal_ids = compact_once(traversal_ids) while traversal_ids.size > limit + + traversal_ids + end + + # The compact_once method finds the most common namespace and compacts all children into an + # entry for that namespace. It then returns the compacted list of traversal_ids. + + def compact_once(traversal_ids) + most_common_namespace_path = find_most_common_namespace_path(traversal_ids) + + compacted_traversal_ids = traversal_ids.map do |traversal_id| + if starts_with?(traversal_id, most_common_namespace_path) + most_common_namespace_path + else + traversal_id + end + end + + compacted_traversal_ids.uniq + end + + # The validate method performs two checks on the compacted_traversal_ids + # 1. If there are redundant traversal_ids, for example [1,2,3,4] and [1,2,3] + # 2. If there are unexpected entries, meaning a traversal_id not present in the origin_project_traversal_ids + # If either case is found, it will raise an error + # Otherwise, it will return true + + def validate!(origin_project_traversal_ids, compacted_traversal_ids) + compacted_traversal_ids.each do |compacted_path| + # Fail if there are unexpected entries + raise UnexpectedCompactionEntry unless origin_project_traversal_ids.find do |original_path| + starts_with?(original_path, compacted_path) + end + + # Fail if there are redundant entries + compacted_traversal_ids.each do |inner_compacted_path| + next if inner_compacted_path == compacted_path + + raise RedundantCompactionEntry if starts_with?(inner_compacted_path, compacted_path) + end + end + + true + end + + private + + # find_most_common_namespace_path method takes an array of traversal_ids and returns the most common namespace + # For example, given the following traversal_ids it would return [1, 2] + # + # [ + # [1, 21], + # [1, 2, 3], + # [1, 2, 4], + # [1, 2, 5], + # [1, 2, 12, 13], + # [1, 6, 7], + # [1, 6, 8], + # [9, 10, 11] + # ] + + def find_most_common_namespace_path(traversal_ids) + # namespace_counts is a tally of the number of times each namespace path occurs in the traversal_ids array + # after removing any namespace paths that occur only once + # The namespace path is the traversal_id without the last element + namespace_counts = traversal_ids.each_with_object([]) do |traversal_id, result| + result << traversal_id[0...-1] if traversal_id.size > 1 + end.tally + + # namespace is the namespace path that occurs the most times in the traversal_ids array after removing + # any namespace paths that occur only once since compaction isn't necessary for those + namespace = namespace_counts.reject { |_k, v| v == 1 }.sort_by { |k, v| [k.size, v] }.reverse.to_h.first + + # if namespace is nil it means there are no more namespaces to compact so + # we raise a CompactionLimitCannotBeAchievedError + raise CompactionLimitCannotBeAchievedError if namespace.nil? + + # return the most common namespace path + namespace.first + end + + # The starts_with? method returns true if the first n elements of the traversal_id match the namespace_path + # For example: + # + # starts_with?([1, 2, 3], [1, 2]) #=> true + # starts_with?([1, 2], [1, 2, 3]) #=> false + # starts_with?([1, 2, 3], [1, 2, 3]) #=> true + # starts_with?([1, 2, 3], [1, 2, 3, 4]) #=> false + + def starts_with?(traversal_id, namespace_path) + traversal_id.first(namespace_path.length) == namespace_path + end + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index a1db8dad256..956036a8b7b 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1483,6 +1483,9 @@ msgstr "" msgid "%{total} warnings found: showing first %{warningsDisplayed}" msgstr "" +msgid "%{type} '%{name}' has been successfully restored." +msgstr "" + msgid "%{type} '%{name}' is being deleted." msgstr "" @@ -14651,6 +14654,9 @@ msgstr "" msgid "Confidentiality" msgstr "" +msgid "Confidentiality controls have moved to the issue actions menu (%{icon}) at the top of the page." +msgstr "" + msgid "Configuration help" msgstr "" @@ -44380,6 +44386,9 @@ msgstr "" msgid "Projects|An error occurred deleting the project. Please refresh the page to try again." msgstr "" +msgid "Projects|An error occurred restoring the project. Please refresh the page to try again." +msgstr "" + msgid "Projects|Projects are where you can store your code, access issues, wiki, and other features of GitLab." msgstr "" @@ -53122,6 +53131,9 @@ msgstr "" msgid "Something went wrong while fetching the packages list." msgstr "" +msgid "Something went wrong while getting %{issuableType} confidentiality status." +msgstr "" + msgid "Something went wrong while obtaining the Let's Encrypt certificate." msgstr "" @@ -62842,18 +62854,27 @@ msgstr "" msgid "WorkItemCustomField|Add option" msgstr "" +msgid "WorkItemCustomField|An error occurred while loading the custom field" +msgstr "" + msgid "WorkItemCustomField|An error occurred while saving the custom field" msgstr "" msgid "WorkItemCustomField|At least one option is required." msgstr "" +msgid "WorkItemCustomField|Edit custom field" +msgstr "" + msgid "WorkItemCustomField|Name" msgstr "" msgid "WorkItemCustomField|Name is required." msgstr "" +msgid "WorkItemCustomField|New custom field" +msgstr "" + msgid "WorkItemCustomField|Options" msgstr "" @@ -63066,9 +63087,6 @@ msgstr "" msgid "WorkItem|Due" msgstr "" -msgid "WorkItem|Edit field" -msgstr "" - msgid "WorkItem|Epic" msgstr "" @@ -63174,9 +63192,6 @@ msgstr "" msgid "WorkItem|New %{workItemType}" msgstr "" -msgid "WorkItem|New custom field" -msgstr "" - msgid "WorkItem|New related %{workItemType}" msgstr "" diff --git a/scripts/semgrep_result_processor.rb b/scripts/semgrep_result_processor.rb index 7a7d5b0fb0e..279fe04fe03 100755 --- a/scripts/semgrep_result_processor.rb +++ b/scripts/semgrep_result_processor.rb @@ -10,6 +10,7 @@ class SemgrepResultProcessor ALLOWED_PROJECT_DIRS = %w[/builds/gitlab-org/gitlab].freeze ALLOWED_API_URLS = %w[https://gitlab.com/api/v4].freeze + UNIQUE_COMMENT_RULES_IDS = %w[builds.sast-custom-rules.appsec-pings.glappsec_ci-job-token builds.sast-custom-rules.secure-coding-guidelines.ruby.glappsec_insecure-regex].freeze # Remove this when the feature is fully working MESSAGE_FOOTER = <<~FOOTER @@ -36,6 +37,7 @@ class SemgrepResultProcessor return end + puts "Found the following unique results: #{unique_results}" create_inline_comments(unique_results) rescue StandardError => e @@ -77,10 +79,11 @@ class SemgrepResultProcessor # Remove version suffix from fingerprint fingerprint = result["extra"]["fingerprint"].sub(/_\d+$/, '') path = result["path"] + check_id = result["check_id"] line = result["start"]["line"] message = result["extra"]["message"].tr('"\'', '') - fingerprint_message_dict[fingerprint] = { path: path, line: line, message: message } + fingerprint_message_dict[fingerprint] = { path: path, line: line, message: message, check_id: check_id } end # Print the results to console @@ -106,6 +109,14 @@ class SemgrepResultProcessor existing_fingerprints = existing_headers.map do |message| JSON.parse(message)["fingerprint"] end + unique_rule_findings = {} + fingerprint_messages.each do |fingerprint, finding| + next unless UNIQUE_COMMENT_RULES_IDS.include?(finding[:check_id]) + + fingerprint_messages.delete(fingerprint) if unique_rule_findings[finding[:check_id]] + + unique_rule_findings[finding[:check_id]] = true + end fingerprint_messages.reject do |fingerprint, _| existing_fingerprints.include?(fingerprint) end @@ -116,7 +127,7 @@ class SemgrepResultProcessor # Create new comments for remaining findings path_line_message_dict.each do |fingerprint, finding| - header_information = JSON.dump({ 'fingerprint' => fingerprint }) + header_information = JSON.dump({ 'fingerprint' => fingerprint, 'check_id' => finding[:check_id] }) message_header = "" new_line = finding[:line] message = finding[:message] diff --git a/spec/features/issues/issue_header_spec.rb b/spec/features/issues/issue_header_spec.rb index 227ecd72c2e..c8863c0a89d 100644 --- a/spec/features/issues/issue_header_spec.rb +++ b/spec/features/issues/issue_header_spec.rb @@ -10,6 +10,7 @@ RSpec.describe 'issue header', :js, feature_category: :team_planning do let_it_be(:closed_issue) { create(:issue, :closed, project: project) } let_it_be(:closed_locked_issue) { create(:issue, :closed, :locked, project: project) } let_it_be(:authored_issue) { create(:issue, project: project, author: user) } + let_it_be(:confidential_issue) { create(:issue, :confidential, project: project) } context 'when user has permission to update' do before do @@ -74,6 +75,32 @@ RSpec.describe 'issue header', :js, feature_category: :team_planning do expect(page).not_to have_button 'Report abuse' end end + + context 'when the issue is not confidential' do + before do + visit project_issue_path(project, issue) + wait_for_requests + end + + it 'shows "Turn on confidentiality" button in dropdown' do + click_button 'Issue actions' + + expect(page).to have_button 'Turn on confidentiality' + end + end + + context 'when the issue is confidential' do + before do + visit project_issue_path(project, confidential_issue) + wait_for_requests + end + + it 'shows "Turn off confidentiality" button in dropdown' do + click_button 'Issue actions' + + expect(page).to have_button 'Turn off confidentiality' + end + end end context 'when user is admin and the project is set up for spam' do @@ -119,6 +146,7 @@ RSpec.describe 'issue header', :js, feature_category: :team_planning do expect(page).to have_button 'Report abuse' expect(page).not_to have_link 'Submit as spam' expect(page).not_to have_button 'Delete issue' + expect(page).not_to have_button 'Turn on confidentiality' end end diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb index ff1119af21f..ecb1320d7da 100644 --- a/spec/features/issues/issue_sidebar_spec.rb +++ b/spec/features/issues/issue_sidebar_spec.rb @@ -139,33 +139,6 @@ RSpec.describe 'Issue Sidebar', feature_category: :team_planning do expect(page).not_to have_selector('.block.escalation-status') end end - - context 'when interacting with collapsed sidebar', :js do - collapsed_sidebar_selector = 'aside.right-sidebar.right-sidebar-collapsed' - expanded_sidebar_selector = 'aside.right-sidebar.right-sidebar-expanded' - confidentiality_sidebar_block = '.block.confidentiality' - collapsed_sidebar_block_icon = '.sidebar-collapsed-icon' - - before do - resize_screen_sm - end - - it 'confidentiality block expands then collapses sidebar' do - expect(page).to have_css(collapsed_sidebar_selector) - - page.within(confidentiality_sidebar_block) do - find(collapsed_sidebar_block_icon).click - end - - expect(page).to have_css(expanded_sidebar_selector) - - page.within(confidentiality_sidebar_block) do - page.find('button', text: 'Cancel').click - end - - expect(page).to have_css(collapsed_sidebar_selector) - end - end end context 'as a guest' do diff --git a/spec/features/issues/user_sees_live_update_spec.rb b/spec/features/issues/user_sees_live_update_spec.rb index 0822542ca02..5070debc926 100644 --- a/spec/features/issues/user_sees_live_update_spec.rb +++ b/spec/features/issues/user_sees_live_update_spec.rb @@ -26,25 +26,23 @@ RSpec.describe 'Issues > User sees live update', :js, feature_category: :team_pl end describe 'confidential issue#show' do - it 'shows confidential sidebar information as confidential and can be turned off' do + it 'shows the confidentiality status that can be turned off' do issue = create(:issue, :confidential, project: project) visit project_issue_path(project, issue) + wait_for_requests - expect(page).to have_text('This is a confidential issue. People without permission will never get a notification.') + expect(page).to have_css('.gl-badge', text: 'Confidential') - within '.block.confidentiality' do - click_button 'Edit' + click_button 'Issue actions' + within '#new-actions-header-dropdown' do + click_button 'Turn off confidentiality' end - expect(page).to have_text('You are going to turn off the confidentiality. This means everyone will be able to see and leave a comment on this issue.') - - click_button 'Turn off' - visit project_issue_path(project, issue) + wait_for_requests expect(page).not_to have_css('.gl-badge', text: 'Confidential') - expect(page).not_to have_text('This is a confidential issue. People without permission will never get a notification.') end end end diff --git a/spec/frontend/issues/show/components/header_actions_confidentiality_toggle_spec.js b/spec/frontend/issues/show/components/header_actions_confidentiality_toggle_spec.js new file mode 100644 index 00000000000..ab2515ec8cf --- /dev/null +++ b/spec/frontend/issues/show/components/header_actions_confidentiality_toggle_spec.js @@ -0,0 +1,123 @@ +import { GlDisclosureDropdownItem } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import waitForPromises from 'helpers/wait_for_promises'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { createAlert } from '~/alert'; +import HeaderActionsConfidentialityToggle from '~/issues/show/components/header_actions_confidentiality_toggle.vue'; +import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql'; +import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_confidential.mutation.graphql'; + +jest.mock('~/alert', () => ({ + createAlert: jest.fn(), +})); + +describe('HeaderActionsConfidentialityToggle', () => { + let wrapper; + let mockApollo; + let mockMutation; + + Vue.use(VueApollo); + + const mockConfidentialityQueryResponse = { + data: { + workspace: { + id: '1', + issuable: { + id: '1', + confidential: false, + }, + }, + }, + }; + + const mockConfidentialityMutationResponse = { + data: { + issuableSetConfidential: { + issuable: { + id: '1', + confidential: false, + }, + errors: [], + }, + }, + }; + + const mockConfidentialityMutationErrorResponse = { + data: { + issuableSetConfidential: { + issuable: { + id: '1', + confidential: false, + }, + errors: ['An error occurred'], + }, + }, + }; + + const createComponent = ({ queryHandler, mutationHandler } = {}) => { + mockMutation = mutationHandler; + mockApollo = createMockApollo([ + [issueConfidentialQuery, queryHandler], + [updateIssueConfidentialMutation, mockMutation], + ]); + + wrapper = shallowMountExtended(HeaderActionsConfidentialityToggle, { + apolloProvider: mockApollo, + provide: { + iid: '1', + issuePath: 'gitlab-org/gitlab-test/-/issues/1', + projectPath: 'gitlab-org/gitlab-test', + fullPath: 'gitlab-org/gitlab-test', + issueType: 'issue', + }, + mocks: { + $toast: { + show: jest.fn(), + }, + }, + stubs: { + GlDisclosureDropdownItem, + }, + }); + }; + + it('renders the component', () => { + createComponent({ + queryHandler: jest.fn().mockResolvedValue(mockConfidentialityQueryResponse), + }); + + expect(wrapper.findComponent(GlDisclosureDropdownItem).exists()).toBe(true); + }); + + it('toggles confidentiality successfully', async () => { + createComponent({ + queryHandler: jest.fn().mockResolvedValue(mockConfidentialityQueryResponse), + mutationHandler: jest.fn().mockResolvedValue(mockConfidentialityMutationResponse), + }); + + wrapper.findComponent(GlDisclosureDropdownItem).vm.$emit('action'); + + await waitForPromises(); + + expect(createAlert).not.toHaveBeenCalled(); + expect(mockMutation).toHaveBeenCalled(); + }); + + it('shows an error alert when toggling confidentiality fails', async () => { + createComponent({ + queryHandler: jest.fn().mockResolvedValue(mockConfidentialityQueryResponse), + mutationHandler: jest.fn().mockResolvedValue(mockConfidentialityMutationErrorResponse), + }); + + wrapper.findComponent(GlDisclosureDropdownItem).vm.$emit('action'); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred', + }); + expect(mockMutation).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/issues/show/components/header_actions_spec.js b/spec/frontend/issues/show/components/header_actions_spec.js index d42e6d51c00..6525c5dd40d 100644 --- a/spec/frontend/issues/show/components/header_actions_spec.js +++ b/spec/frontend/issues/show/components/header_actions_spec.js @@ -38,6 +38,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql'; import updateIssueMutation from '~/issues/show/queries/update_issue.mutation.graphql'; import toast from '~/vue_shared/plugins/global_toast'; +import HeaderActionsConfidentialityToggle from '~/issues/show/components/header_actions_confidentiality_toggle.vue'; jest.mock('~/alert'); jest.mock('~/issues/show/event_hub', () => ({ $emit: jest.fn() })); @@ -602,6 +603,29 @@ describe('HeaderActions component', () => { }); }); + describe('toggle confidentiality option', () => { + it.each` + issueType | canUpdateIssue | isVisible | showHide + ${TYPE_ISSUE} | ${true} | ${true} | ${'shows'} + ${TYPE_INCIDENT} | ${true} | ${true} | ${'shows'} + ${TYPE_ISSUE} | ${false} | ${false} | ${'hides'} + ${TYPE_INCIDENT} | ${false} | ${false} | ${'hides'} + ${'some_other_type'} | ${true} | ${false} | ${'hides'} + `( + '$showHide toggle confidentiality option for issueType $issueType and canUpdateIssue $canUpdateIssue', + ({ issueType, canUpdateIssue, isVisible }) => { + wrapper = mountComponent({ + props: { + issueType, + canUpdateIssue, + }, + }); + + expect(wrapper.findComponent(HeaderActionsConfidentialityToggle).exists()).toBe(isVisible); + }, + ); + }); + describe('issue type text', () => { it.each` issueType | expectedText diff --git a/spec/frontend/vue_shared/components/projects_list/formatter_spec.js b/spec/frontend/vue_shared/components/projects_list/formatter_spec.js index 3dd55664c5d..98dea1e4312 100644 --- a/spec/frontend/vue_shared/components/projects_list/formatter_spec.js +++ b/spec/frontend/vue_shared/components/projects_list/formatter_spec.js @@ -5,7 +5,7 @@ import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions const MOCK_AVAILABLE_ACTIONS = [ACTION_EDIT, ACTION_DELETE]; -jest.mock('~/vue_shared/components/projects_list/utils', () => ({ +jest.mock('ee_else_ce/vue_shared/components/projects_list/utils', () => ({ availableGraphQLProjectActions: jest.fn(() => MOCK_AVAILABLE_ACTIONS), })); diff --git a/spec/frontend/vue_shared/components/projects_list/project_list_item_actions_spec.js b/spec/frontend/vue_shared/components/projects_list/project_list_item_actions_spec.js new file mode 100644 index 00000000000..c77d9576e8c --- /dev/null +++ b/spec/frontend/vue_shared/components/projects_list/project_list_item_actions_spec.js @@ -0,0 +1,61 @@ +import projects from 'test_fixtures/api/users/projects/get.json'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import ListActions from '~/vue_shared/components/list_actions/list_actions.vue'; +import ProjectListItemActions from '~/vue_shared/components/projects_list/project_list_item_actions.vue'; +import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants'; + +describe('ProjectListItemActionsCE', () => { + let wrapper; + + const [project] = convertObjectPropsToCamelCase(projects, { deep: true }); + + const editPath = '/foo/bar/edit'; + const projectWithActions = { + ...project, + availableActions: [ACTION_EDIT, ACTION_DELETE], + editPath, + }; + + const defaultProps = { + project: projectWithActions, + }; + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMountExtended(ProjectListItemActions, { + propsData: { ...defaultProps, ...props }, + }); + }; + + const findListActions = () => wrapper.findComponent(ListActions); + + beforeEach(() => { + createComponent(); + }); + + describe('template', () => { + it('displays actions dropdown', () => { + expect(findListActions().props()).toMatchObject({ + actions: { + [ACTION_EDIT]: { + href: editPath, + }, + [ACTION_DELETE]: { + action: expect.any(Function), + }, + }, + availableActions: [ACTION_EDIT, ACTION_DELETE], + }); + }); + }); + + describe('when delete action is fired', () => { + beforeEach(() => { + findListActions().props('actions')[ACTION_DELETE].action(); + }); + + it('emits delete event', () => { + expect(wrapper.emitted('delete')).toEqual([[]]); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js b/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js index 959549f6dad..1960e29a5e2 100644 --- a/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js +++ b/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js @@ -4,9 +4,9 @@ import uniqueId from 'lodash/uniqueId'; import projects from 'test_fixtures/api/users/projects/get.json'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import ProjectListItemDescription from 'ee_else_ce/vue_shared/components/projects_list/project_list_item_description.vue'; +import ProjectListItemActions from 'ee_else_ce/vue_shared/components/projects_list/project_list_item_actions.vue'; import ProjectListItemInactiveBadge from 'ee_else_ce/vue_shared/components/projects_list/project_list_item_inactive_badge.vue'; import ProjectsListItem from '~/vue_shared/components/projects_list/projects_list_item.vue'; -import ListActions from '~/vue_shared/components/list_actions/list_actions.vue'; import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; @@ -75,7 +75,7 @@ describe('ProjectsListItem', () => { const findProjectTopics = () => wrapper.findByTestId('project-topics'); const findPopover = () => findProjectTopics().findComponent(GlPopover); const findVisibilityIcon = () => findAvatarLabeled().findComponent(GlIcon); - const findListActions = () => wrapper.findComponent(ListActions); + const findListActions = () => wrapper.findComponent(ProjectListItemActions); const findAccessLevelBadge = () => wrapper.findByTestId('access-level-badge'); const findCiCatalogBadge = () => wrapper.findByTestId('ci-catalog-badge'); const findProjectDescription = () => wrapper.findComponent(ProjectListItemDescription); @@ -429,22 +429,12 @@ describe('ProjectsListItem', () => { }); it('displays actions dropdown', () => { - expect(findListActions().props()).toMatchObject({ - actions: { - [ACTION_EDIT]: { - href: editPath, - }, - [ACTION_DELETE]: { - action: expect.any(Function), - }, - }, - availableActions: [ACTION_EDIT, ACTION_DELETE], - }); + expect(findListActions().exists()).toBe(true); }); describe('when delete action is fired', () => { beforeEach(() => { - findListActions().props('actions')[ACTION_DELETE].action(); + findListActions().vm.$emit('delete'); }); it('displays confirmation modal with correct props', () => { diff --git a/spec/graphql/types/merge_request_type_spec.rb b/spec/graphql/types/merge_request_type_spec.rb index d3e664271c1..bc1b0e02ce1 100644 --- a/spec/graphql/types/merge_request_type_spec.rb +++ b/spec/graphql/types/merge_request_type_spec.rb @@ -22,6 +22,7 @@ RSpec.describe GitlabSchema.types['MergeRequest'], feature_category: :code_revie project project_id source_project_id target_project_id source_branch target_branch target_branch_path draft merge_when_pipeline_succeeds diff_head_sha merge_commit_sha user_notes_count user_discussions_count should_remove_source_branch + resolvable_discussions_count resolved_discussions_count diff_refs diff_stats diff_stats_summary force_remove_source_branch merge_status merge_status_enum @@ -247,4 +248,70 @@ RSpec.describe GitlabSchema.types['MergeRequest'], feature_category: :code_revie end end end + + describe '#resolvable_discussions_count' do + let_it_be(:project) { create(:project, :public, :repository) } + let_it_be(:merge_request) { create(:merge_request, source_project: project) } + let_it_be(:note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project) } + + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + mergeRequests { + nodes { + resolvableDiscussionsCount + } + } + } + } + ) + end + + it 'returns resolvable discussions count' do + result = GitlabSchema.execute(query, context: { current_user: create(:user) }) + + expect(result.dig('data', 'project', 'mergeRequests', 'nodes', 0, 'resolvableDiscussionsCount')).to eq(1) + end + + it_behaves_like 'avoids N+1 queries' do + def create_additional_resources + noteable = create(:merge_request, source_project: project, source_branch: 'improve/awesome') + create(:discussion_note_on_merge_request, noteable: noteable, project: project) + end + end + end + + describe '#resolved_discussions_count' do + let_it_be(:project) { create(:project, :public, :repository) } + let_it_be(:merge_request) { create(:merge_request, source_project: project) } + let_it_be(:note) { create(:discussion_note_on_merge_request, :resolved, noteable: merge_request, project: project) } + + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + mergeRequests { + nodes { + resolvedDiscussionsCount + } + } + } + } + ) + end + + it 'returns resolved discussions count' do + result = GitlabSchema.execute(query, context: { current_user: create(:user) }) + + expect(result.dig('data', 'project', 'mergeRequests', 'nodes', 0, 'resolvedDiscussionsCount')).to eq(1) + end + + it_behaves_like 'avoids N+1 queries' do + def create_additional_resources + noteable = create(:merge_request, source_project: project, source_branch: 'improve/awesome') + create(:discussion_note_on_merge_request, :resolved, noteable: noteable, project: project) + end + end + end end diff --git a/spec/lib/gitlab/legacy_github_import/user_formatter_spec.rb b/spec/lib/gitlab/legacy_github_import/user_formatter_spec.rb index b51de92c32a..4b35925636b 100644 --- a/spec/lib/gitlab/legacy_github_import/user_formatter_spec.rb +++ b/spec/lib/gitlab/legacy_github_import/user_formatter_spec.rb @@ -139,6 +139,30 @@ RSpec.describe Gitlab::LegacyGithubImport::UserFormatter, feature_category: :imp expect { user_formatter.source_user }.to change { Import::SourceUser.count }.from(0).to(1) expect(user_formatter.source_user.class).to eq(Import::SourceUser) end + + it "creates a placeholder with the user's full name and username" do + source_user = user_formatter.source_user + + expect(source_user).to have_attributes( + source_user_identifier: gitea_user[:id].to_s, + source_username: gitea_user[:login], + source_name: gitea_user[:full_name] + ) + end + + context 'when the gitea user has no full name' do + let(:gitea_user) { { id: 123456, login: 'octocat', email: 'user@email.com', full_name: '' } } + + it 'falls back to the gitea username' do + source_user = user_formatter.source_user + + expect(source_user).to have_attributes( + source_user_identifier: gitea_user[:id].to_s, + source_username: gitea_user[:login], + source_name: gitea_user[:login] + ) + end + end end context 'and a source user already exists' do diff --git a/spec/lib/gitlab/utils/traversal_id_compactor_spec.rb b/spec/lib/gitlab/utils/traversal_id_compactor_spec.rb new file mode 100644 index 00000000000..5612994374d --- /dev/null +++ b/spec/lib/gitlab/utils/traversal_id_compactor_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Utils::TraversalIdCompactor, feature_category: :secrets_management do + let(:traversal_ids) do + [ + [1, 21], + [1, 2, 3], + [1, 2, 4], + [1, 2, 5], + [1, 2, 12, 13], + [1, 6, 7], + [1, 6, 8], + [9, 10, 11] + ] + end + + let(:compactor) { described_class } + + describe '#compact' do + it 'compacts the array of traversal_ids using compact_once two times until the limit is reached' do + expect(compactor).to receive(:compact_once).twice.and_call_original + + result = compactor.compact(traversal_ids, 4) + + expect(result).to eq([ + [1, 21], + [1, 2], + [1, 6], + [9, 10, 11] + ]) + end + + it 'compacts the array of traversal_ids using compact_once three times until the limit is reached' do + expect(compactor).to receive(:compact_once).exactly(3).times.and_call_original + + result = compactor.compact(traversal_ids, 3) + + expect(result).to eq([ + [1], + [9, 10, 11] + ]) + end + + it 'compacts the array of traversal_ids using compact_once one time to reach the limit' do + traversal_ids = [ + [1, 2], + [1, 3], + [1, 4], + [5, 6], + [6, 7] + ] + + expect(compactor).to receive(:compact_once).once.and_call_original + + result = compactor.compact(traversal_ids, 3) + + expect(result).to eq([ + [1], + [5, 6], + [6, 7] + ]) + end + + it 'raises when the compaction limit can not be achieved' do + expect do + compactor.compact(traversal_ids, 1) + end.to raise_error(described_class::CompactionLimitCannotBeAchievedError) + end + end + + describe '#compact_once' do + it 'compacts the one most common namespace path and returns the newly compacted array of traversal_ids' do + result = compactor.compact_once(traversal_ids) + + expect(result).to eq([ + [1, 21], + [1, 2], + [1, 6, 7], + [1, 6, 8], + [9, 10, 11] + ]) + end + end + + describe '#validate!' do + it 'returns true when the compacted results are valid' do + result = compactor.compact(traversal_ids, 4) + expect(compactor.validate!(traversal_ids, result)).to be true + end + + it 'raises a RedundantCompactionEntry error when redundant entries are found' do + result = compactor.compact(traversal_ids, 4) + result << [1, 2, 3] + expect do + compactor.validate!(traversal_ids, result) + end.to raise_error(described_class::RedundantCompactionEntry) + end + + it 'raises an UnexpectedCompactionEntry error when an unexpected entry is found' do + result = compactor.compact(traversal_ids, 4) + result << [1, 3, 4] + expect do + compactor.validate!(traversal_ids, result) + end.to raise_error(described_class::UnexpectedCompactionEntry) + end + end +end diff --git a/spec/models/ci/job_token/authorizations_compactor_spec.rb b/spec/models/ci/job_token/authorizations_compactor_spec.rb new file mode 100644 index 00000000000..0ad1e92d2f2 --- /dev/null +++ b/spec/models/ci/job_token/authorizations_compactor_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::JobToken::AuthorizationsCompactor, feature_category: :secrets_management do + let_it_be(:accessed_project) { create(:project) } + let(:compactor) { described_class.new(accessed_project.id) } + + # [1, 21], ns1, p1 + # [1, 2, 3], ns1, ns2, p2 + # [1, 2, 4], ns1, ns2, p3 + # [1, 2, 5], ns1, ns2, p4 + # [1, 2, 12, 13], ns1, ns2, ns3, p5 + # [1, 6, 7], ns1, ns4, p6 + # [1, 6, 8], ns1, ns4, p7 + # [9, 10, 11] ns5, ns6, p8 + + let_it_be(:ns1) { create(:group, name: 'ns1') } + let_it_be(:ns2) { create(:group, parent: ns1, name: 'ns2') } + let_it_be(:ns3) { create(:group, parent: ns2, name: 'ns3') } + let_it_be(:ns4) { create(:group, parent: ns1, name: 'ns4') } + let_it_be(:ns5) { create(:group, name: 'ns5') } + let_it_be(:ns6) { create(:group, parent: ns5, name: 'ns6') } + + let_it_be(:pns1) { create(:project_namespace, parent: ns1) } + let_it_be(:pns2) { create(:project_namespace, parent: ns2) } + let_it_be(:pns3) { create(:project_namespace, parent: ns2) } + let_it_be(:pns4) { create(:project_namespace, parent: ns2) } + let_it_be(:pns5) { create(:project_namespace, parent: ns3) } + let_it_be(:pns6) { create(:project_namespace, parent: ns4) } + let_it_be(:pns7) { create(:project_namespace, parent: ns4) } + let_it_be(:pns8) { create(:project_namespace, parent: ns6) } + + before do + origin_project_namespaces = [ + pns1, pns2, pns3, pns4, pns5, pns6, pns7, pns8 + ] + + origin_project_namespaces.each do |project_namespace| + create(:ci_job_token_authorization, origin_project: project_namespace.project, accessed_project: accessed_project, + last_authorized_at: 1.day.ago) + end + end + + describe '#compact' do + it 'compacts the allowlist groups and projects as expected for the given limit' do + compactor.compact(4) + + expect(compactor.allowlist_groups).to match_array([ns2, ns4]) + expect(compactor.allowlist_projects).to match_array([pns1.project, pns8.project]) + end + + it 'compacts the allowlist groups and projects as expected for the given limit' do + compactor.compact(3) + + expect(compactor.allowlist_groups).to match_array([ns1]) + expect(compactor.allowlist_projects).to match_array([pns8.project]) + end + + it 'raises when the limit cannot be achieved' do + expect do + compactor.compact(1) + end.to raise_error(Gitlab::Utils::TraversalIdCompactor::CompactionLimitCannotBeAchievedError) + end + + it 'raises when an unexpected compaction entry is found' do + allow(Gitlab::Utils::TraversalIdCompactor).to receive(:compact).and_wrap_original do |original_method, *args| + original_response = original_method.call(*args) + original_response << [1, 2, 3] + end + + expect { compactor.compact(5) }.to raise_error(Gitlab::Utils::TraversalIdCompactor::UnexpectedCompactionEntry) + end + + it 'raises when a redundant compaction entry is found' do + allow(Gitlab::Utils::TraversalIdCompactor).to receive(:compact).and_wrap_original do |original_method, *args| + original_response = original_method.call(*args) + original_response << original_response.last.first(2) + end + + expect { compactor.compact(5) }.to raise_error(Gitlab::Utils::TraversalIdCompactor::RedundantCompactionEntry) + end + + context 'with three top-level namespaces' do + # [1, 21], ns1, p1 + # [1, 2, 3], ns1, ns2, p2 + # [1, 2, 4], ns1, ns2, p3 + # [1, 2, 5], ns1, ns2, p4 + # [1, 2, 12, 13], ns1, ns2, ns3, p5 + # [1, 6, 7], ns1, ns4, p6 + # [1, 6, 8], ns1, ns4, p7 + # [9, 10, 11] ns5, ns6, p8 + # [14, 15] ns7, p9 + let(:ns7) { create(:group, name: 'ns7') } + let(:pns9) { create(:project_namespace, parent: ns7) } + + before do + create(:ci_job_token_authorization, origin_project: pns9.project, accessed_project: accessed_project, + last_authorized_at: 1.day.ago) + end + + it 'raises when the limit cannot be achieved' do + expect do + compactor.compact(2) + end.to raise_error(Gitlab::Utils::TraversalIdCompactor::CompactionLimitCannotBeAchievedError) + end + + it 'does not raise when the limit cannot be achieved' do + expect do + compactor.compact(3) + end.not_to raise_error + end + end + end +end diff --git a/spec/scripts/semgrep_result_processor_spec.rb b/spec/scripts/semgrep_result_processor_spec.rb index 9daf0029a1b..3a2f15b153e 100644 --- a/spec/scripts/semgrep_result_processor_spec.rb +++ b/spec/scripts/semgrep_result_processor_spec.rb @@ -125,27 +125,31 @@ RSpec.describe SemgrepResultProcessor, feature_category: :tooling do let(:existing_comments) do [ - { "body" => " Some comment ", "author" => { "id" => 123 } }, - { "body" => " Another comment ", "author" => { "id" => 123 } }, - { "body" => " Yet another comment ", "author" => { "id" => 123 } } + { "body" => " Some comment ", + "author" => { "id" => 123 } }, + { "body" => " Another comment ", + "author" => { "id" => 123 } }, + { "body" => " Yet another comment ", + "author" => { "id" => 123 } } ] end - let(:fingerprint_messages) do + let(:first_unique_rule_id) { described_class::UNIQUE_COMMENT_RULES_IDS.first } + let(:new_run_findings) do { - "abc123" => "Duplicate finding 1", - "def456" => "Duplicate finding 2", - "new123" => "New finding 1", - "new456" => "New finding 2" + "abc123" => { path: "path/to/file1.rb", line: 10, message: "Duplicate finding 1", check_id: "RULE1" }, + "def456" => { path: "path/to/file2.rb", line: 20, message: "Duplicate finding 2", check_id: "RULE2" }, + "new123" => { path: "path/to/file3.rb", line: 30, message: "New finding 1", check_id: "RULE3" }, + "new456" => { path: "path/to/file4.rb", line: 40, message: "New finding 2", check_id: "RULE4" } } end it 'filters out findings with fingerprints that are already in comments from the bot' do - result = processor.filter_duplicate_findings(fingerprint_messages) + result = processor.filter_duplicate_findings(new_run_findings) expect(result).to eq({ - "new123" => "New finding 1", - "new456" => "New finding 2" + "new123" => { path: "path/to/file3.rb", line: 30, message: "New finding 1", check_id: "RULE3" }, + "new456" => { path: "path/to/file4.rb", line: 40, message: "New finding 2", check_id: "RULE4" } }) end @@ -155,9 +159,9 @@ RSpec.describe SemgrepResultProcessor, feature_category: :tooling do { "body" => " Another comment", "author" => { "id" => 456 } } ]) - result = processor.filter_duplicate_findings(fingerprint_messages) + result = processor.filter_duplicate_findings(new_run_findings) - expect(result).to eq(fingerprint_messages) + expect(result).to eq(new_run_findings) end it 'returns an empty hash if all fingerprints are already in bot comments' do @@ -168,17 +172,34 @@ RSpec.describe SemgrepResultProcessor, feature_category: :tooling do { "body" => " Another existing comment", "author" => { "id" => 123 } } ]) - result = processor.filter_duplicate_findings(fingerprint_messages) + result = processor.filter_duplicate_findings(new_run_findings) expect(result).to eq({}) end + + it 'filters out findings with check_ids that are in the UNIQUE_COMMENT_RULES_IDS list' do + new_run_findings["new789"] = + { path: "path/to/file4.rb", line: 40, message: "New finding 2", check_id: first_unique_rule_id } + new_run_findings["new890"] = + { path: "path/to/file4.rb", line: 40, message: "New finding 2", check_id: first_unique_rule_id } + result = processor.filter_duplicate_findings(new_run_findings) + + expect(result).to eq({ + "new123" => { path: "path/to/file3.rb", line: 30, message: "New finding 1", + check_id: "RULE3" }, + "new456" => { path: "path/to/file4.rb", line: 40, message: "New finding 2", + check_id: "RULE4" }, + "new789" => { path: "path/to/file4.rb", line: 40, message: "New finding 2", + check_id: first_unique_rule_id } + }) + end end describe '#get_sast_results' do let(:sample_non_versioned_fingerprint) { "a5adf24a2512f31141f460e0bc18f39c8388105e564f" } let(:sample_message) { "This is a sample SAST finding message" } let(:scanned_path) { "ee/lib/ai/context/dependencies/config_files/python_pip.rb" } - + let(:check_id) { "builds.sast-custom-rules.secure-coding-guidelines.ruby.glappsec_insecure-regex" } let(:sample_data) do { "errors" => [], @@ -194,7 +215,7 @@ RSpec.describe SemgrepResultProcessor, feature_category: :tooling do }, "results" => [ { - "check_id" => "builds.sast-custom-rules.secure-coding-guidelines.ruby.glappsec_insecure-regex", + "check_id" => check_id, "path" => scanned_path, "start" => { "line" => 9, "col" => 11, "offset" => 178 }, "end" => { "line" => 9, "col" => 93, "offset" => 260 }, @@ -204,7 +225,7 @@ RSpec.describe SemgrepResultProcessor, feature_category: :tooling do } }, { - "check_id" => "builds.sast-custom-rules.secure-coding-guidelines.ruby.glappsec_insecure-regex", + "check_id" => check_id, "path" => scanned_path, "start" => { "line" => 9, "col" => 32, "offset" => 199 }, "end" => { "line" => 9, "col" => 93, "offset" => 260 }, @@ -227,12 +248,12 @@ RSpec.describe SemgrepResultProcessor, feature_category: :tooling do it 'parses the SAST report and prints findings correctly' do expected_output = { sample_non_versioned_fingerprint => { + check_id: "builds.sast-custom-rules.secure-coding-guidelines.ruby.glappsec_insecure-regex", path: "ee/lib/ai/context/dependencies/config_files/python_pip.rb", line: 9, message: sample_message } } - result = processor.get_sast_results expect(result).to eq(expected_output) @@ -252,6 +273,23 @@ RSpec.describe SemgrepResultProcessor, feature_category: :tooling do expect { processor.get_sast_results }.to raise_error(SystemExit).and output(/No findings./).to_stdout end + + context 'when check_id is absent from SAST results' do + let(:check_id) { nil } + + it 'returns an empty check_id' do + expected_output = { + sample_non_versioned_fingerprint => { + check_id: check_id, + path: "ee/lib/ai/context/dependencies/config_files/python_pip.rb", + line: 9, + message: sample_message + } + } + result = processor.get_sast_results + expect(result).to eq(expected_output) + end + end end describe '#create_inline_comments' do