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