From c32f32d49ed3f496b1580e44935e49e11227d921 Mon Sep 17 00:00:00 2001
From: Michael Becker <mbecker@gitlab.com>
Date: Thu, 3 Oct 2024 03:37:43 +0000
Subject: [PATCH] Add a hasAiResolution filter to
 vulnerabilitySeveritiesCountResolver

We need to be able to filter vulnerabilities on whether they have the
"Resolve with Duo" button enabled.

This button is enabled if a finding's `CWE` value is included in this
[hard-coded list][0] of `CWE` values.

In a previous MRs we exposed this filter in the
`VulnerabilitiesResolver`[^1]

This commit updates the `vulnerabilitySeveritiesCountResolver` to make
use of the same filter.

[^1]: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/166312

---

Changelog: changed
EE: true
MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/167433
epic: https://gitlab.com/groups/gitlab-org/-/epics/15036
Related to: https://gitlab.com/gitlab-org/gitlab/-/issues/481529
---
 doc/api/graphql/reference/index.md            |  3 +
 .../resolvers/vulnerabilities_resolver.rb     |  4 +-
 ...vulnerability_severities_count_resolver.rb | 12 ++++
 ...rability_severities_count_resolver_spec.rb | 59 +++++++++++++++++++
 .../vulnerability_severities_count_spec.rb    | 45 +++++++++++++-
 5 files changed, 120 insertions(+), 3 deletions(-)

diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 7ac27c20ae0b..2646bd61e62c 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -24597,6 +24597,7 @@ Returns [`VulnerabilitySeveritiesCount`](#vulnerabilityseveritiescount).
 | <a id="groupvulnerabilityseveritiescountcapped"></a>`capped` | [`Boolean`](#boolean) | Default value is false. When set to true, the count returned for each severity is capped at a maximum of 1001. |
 | <a id="groupvulnerabilityseveritiescountclusteragentid"></a>`clusterAgentId` | [`[ClustersAgentID!]`](#clustersagentid) | Filter vulnerabilities by `cluster_agent_id`. Vulnerabilities with a `reportType` of `cluster_image_scanning` are only included with this filter. |
 | <a id="groupvulnerabilityseveritiescountdismissalreason"></a>`dismissalReason` | [`[VulnerabilityDismissalReason!]`](#vulnerabilitydismissalreason) | Filter by dismissal reason. |
+| <a id="groupvulnerabilityseveritiescounthasairesolution"></a>`hasAiResolution` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in GitLab 17.5. **Status**: Experiment. Filters vulnerabilities which can or can not be resolved by GitLab Duo Vulnerability Resolution. Requires the `vulnerability_report_vr_filter` feature flag to be enabled, otherwise the argument is ignored. |
 | <a id="groupvulnerabilityseveritiescounthasissues"></a>`hasIssues` | [`Boolean`](#boolean) | Filter vulnerabilities that do or do not have issues. |
 | <a id="groupvulnerabilityseveritiescounthasmergerequest"></a>`hasMergeRequest` | [`Boolean`](#boolean) | Filter vulnerabilities that do or do not have a merge request. |
 | <a id="groupvulnerabilityseveritiescounthasremediations"></a>`hasRemediations` | [`Boolean`](#boolean) | Filter vulnerabilities that do or do not have remediations. |
@@ -25289,6 +25290,7 @@ Returns [`VulnerabilitySeveritiesCount`](#vulnerabilityseveritiescount).
 | <a id="instancesecuritydashboardvulnerabilityseveritiescountcapped"></a>`capped` | [`Boolean`](#boolean) | Default value is false. When set to true, the count returned for each severity is capped at a maximum of 1001. |
 | <a id="instancesecuritydashboardvulnerabilityseveritiescountclusteragentid"></a>`clusterAgentId` | [`[ClustersAgentID!]`](#clustersagentid) | Filter vulnerabilities by `cluster_agent_id`. Vulnerabilities with a `reportType` of `cluster_image_scanning` are only included with this filter. |
 | <a id="instancesecuritydashboardvulnerabilityseveritiescountdismissalreason"></a>`dismissalReason` | [`[VulnerabilityDismissalReason!]`](#vulnerabilitydismissalreason) | Filter by dismissal reason. |
+| <a id="instancesecuritydashboardvulnerabilityseveritiescounthasairesolution"></a>`hasAiResolution` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in GitLab 17.5. **Status**: Experiment. Filters vulnerabilities which can or can not be resolved by GitLab Duo Vulnerability Resolution. Requires the `vulnerability_report_vr_filter` feature flag to be enabled, otherwise the argument is ignored. |
 | <a id="instancesecuritydashboardvulnerabilityseveritiescounthasissues"></a>`hasIssues` | [`Boolean`](#boolean) | Filter vulnerabilities that do or do not have issues. |
 | <a id="instancesecuritydashboardvulnerabilityseveritiescounthasmergerequest"></a>`hasMergeRequest` | [`Boolean`](#boolean) | Filter vulnerabilities that do or do not have a merge request. |
 | <a id="instancesecuritydashboardvulnerabilityseveritiescounthasremediations"></a>`hasRemediations` | [`Boolean`](#boolean) | Filter vulnerabilities that do or do not have remediations. |
@@ -31553,6 +31555,7 @@ Returns [`VulnerabilitySeveritiesCount`](#vulnerabilityseveritiescount).
 | <a id="projectvulnerabilityseveritiescountcapped"></a>`capped` | [`Boolean`](#boolean) | Default value is false. When set to true, the count returned for each severity is capped at a maximum of 1001. |
 | <a id="projectvulnerabilityseveritiescountclusteragentid"></a>`clusterAgentId` | [`[ClustersAgentID!]`](#clustersagentid) | Filter vulnerabilities by `cluster_agent_id`. Vulnerabilities with a `reportType` of `cluster_image_scanning` are only included with this filter. |
 | <a id="projectvulnerabilityseveritiescountdismissalreason"></a>`dismissalReason` | [`[VulnerabilityDismissalReason!]`](#vulnerabilitydismissalreason) | Filter by dismissal reason. |
+| <a id="projectvulnerabilityseveritiescounthasairesolution"></a>`hasAiResolution` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in GitLab 17.5. **Status**: Experiment. Filters vulnerabilities which can or can not be resolved by GitLab Duo Vulnerability Resolution. Requires the `vulnerability_report_vr_filter` feature flag to be enabled, otherwise the argument is ignored. |
 | <a id="projectvulnerabilityseveritiescounthasissues"></a>`hasIssues` | [`Boolean`](#boolean) | Filter vulnerabilities that do or do not have issues. |
 | <a id="projectvulnerabilityseveritiescounthasmergerequest"></a>`hasMergeRequest` | [`Boolean`](#boolean) | Filter vulnerabilities that do or do not have a merge request. |
 | <a id="projectvulnerabilityseveritiescounthasremediations"></a>`hasRemediations` | [`Boolean`](#boolean) | Filter vulnerabilities that do or do not have remediations. |
diff --git a/ee/app/graphql/resolvers/vulnerabilities_resolver.rb b/ee/app/graphql/resolvers/vulnerabilities_resolver.rb
index c1a734f7c988..71d3f0efa4e2 100644
--- a/ee/app/graphql/resolvers/vulnerabilities_resolver.rb
+++ b/ee/app/graphql/resolvers/vulnerabilities_resolver.rb
@@ -115,12 +115,12 @@ def preloads
 
     def vulnerabilities(params)
       finder_params = params.merge(before_severity: before_severity, after_severity: after_severity)
-      finder_params.delete(:has_ai_resolution) unless resolve_by_duo_filtering_enabled?
+      finder_params.delete(:has_ai_resolution) unless resolve_with_duo_filtering_enabled?
 
       apply_lookahead(::Security::VulnerabilityReadsFinder.new(vulnerable, finder_params).execute.as_vulnerabilities)
     end
 
-    def resolve_by_duo_filtering_enabled?
+    def resolve_with_duo_filtering_enabled?
       return false if vulnerable.is_a?(::InstanceSecurityDashboard)
 
       Feature.enabled?(:vulnerability_report_vr_filter, vulnerable)
diff --git a/ee/app/graphql/resolvers/vulnerability_severities_count_resolver.rb b/ee/app/graphql/resolvers/vulnerability_severities_count_resolver.rb
index ec9e60fd7a72..65b63395b516 100644
--- a/ee/app/graphql/resolvers/vulnerability_severities_count_resolver.rb
+++ b/ee/app/graphql/resolvers/vulnerability_severities_count_resolver.rb
@@ -48,6 +48,11 @@ class VulnerabilitySeveritiesCountResolver < VulnerabilitiesBaseResolver
       required: false,
       description: 'Filter vulnerabilities that do or do not have a resolution.'
 
+    argument :has_ai_resolution, GraphQL::Types::Boolean,
+      required: false,
+      alpha: { milestone: '17.5' },
+      description: 'Filters vulnerabilities which can or can not be resolved by GitLab Duo Vulnerability Resolution. Requires the `vulnerability_report_vr_filter` feature flag to be enabled, otherwise the argument is ignored.'
+
     argument :image, [GraphQL::Types::String],
       required: false,
       description: "Filter vulnerabilities by location image. When this filter is present, "\
@@ -94,7 +99,14 @@ def resolve(**args)
     private
 
     def vulnerabilities(filters)
+      filters.delete(:has_ai_resolution) unless resolve_with_duo_filtering_enabled?
       ::Security::VulnerabilityReadsFinder.new(vulnerable, filters).execute
     end
+
+    def resolve_with_duo_filtering_enabled?
+      return false if vulnerable.is_a?(::InstanceSecurityDashboard)
+
+      Feature.enabled?(:vulnerability_report_vr_filter, vulnerable)
+    end
   end
 end
diff --git a/ee/spec/graphql/resolvers/vulnerability_severities_count_resolver_spec.rb b/ee/spec/graphql/resolvers/vulnerability_severities_count_resolver_spec.rb
index 9b112be94a8c..fad93eb308fa 100644
--- a/ee/spec/graphql/resolvers/vulnerability_severities_count_resolver_spec.rb
+++ b/ee/spec/graphql/resolvers/vulnerability_severities_count_resolver_spec.rb
@@ -36,6 +36,7 @@
 
     let_it_be(:critical_vulnerability) do
       create(:vulnerability, :with_findings, :detected, :critical, :sast, :with_merge_request_links, project: project)
+        .tap { |v| v.vulnerability_read.update!(has_vulnerability_resolution: true) }
     end
 
     let_it_be(:high_vulnerability) do
@@ -200,6 +201,46 @@
         end
       end
 
+      context 'when filtering vulnerabilities with AI resolution' do
+        context 'when has_ai_resolution is set to true' do
+          let(:filters) { { has_ai_resolution: true } }
+
+          it 'only returns count for vulnerabilities with AI resolutions' do
+            is_expected.to eq('critical' => 1)
+          end
+        end
+
+        context 'when has_ai_resolution is set to false' do
+          let(:filters) { { has_ai_resolution: false } }
+
+          it 'only returns count for vulnerabilities without AI resolutions' do
+            is_expected.to eq('high' => 1, 'low' => 1, 'medium' => 1)
+          end
+        end
+
+        context 'when vulnerability_report_vr_filter FF is disabled' do
+          before do
+            stub_feature_flags(vulnerability_report_vr_filter: false)
+          end
+
+          context 'when has_ai_resolution is set to true' do
+            let(:filters) { { has_ai_resolution: true } }
+
+            it 'only ignores filter and returns all count' do
+              is_expected.to eq('critical' => 1, 'high' => 1, 'low' => 1, 'medium' => 1)
+            end
+          end
+
+          context 'when has_ai_resolution is set to false' do
+            let(:filters) { { has_ai_resolution: false } }
+
+            it 'only ignores filter and returns all count' do
+              is_expected.to eq('critical' => 1, 'high' => 1, 'low' => 1, 'medium' => 1)
+            end
+          end
+        end
+      end
+
       context 'when filtering vulnerabilities with remediations' do
         let(:filters) { { has_remediations: true } }
         let_it_be(:vulnerability_read_with_remediations) { create(:vulnerability_read, :with_remediations, project: project) }
@@ -344,6 +385,24 @@
           end
         end
 
+        context 'when filtering vulnerabilities with AI resolution' do
+          context 'when has_ai_resolution is set to true' do
+            let(:filters) { { has_ai_resolution: true } }
+
+            it 'only ignores filter and returns all count' do
+              is_expected.to eq('critical' => 1, 'high' => 1, 'low' => 1, 'medium' => 1)
+            end
+          end
+
+          context 'when has_ai_resolution is set to false' do
+            let(:filters) { { has_ai_resolution: false } }
+
+            it 'only ignores filter and returns all count' do
+              is_expected.to eq('critical' => 1, 'high' => 1, 'low' => 1, 'medium' => 1)
+            end
+          end
+        end
+
         it_behaves_like 'vulnerability filterable', :filters
       end
 
diff --git a/ee/spec/requests/api/graphql/project/vulnerability_severities_count_spec.rb b/ee/spec/requests/api/graphql/project/vulnerability_severities_count_spec.rb
index d2c1746a280e..40cc57a7fd5f 100644
--- a/ee/spec/requests/api/graphql/project/vulnerability_severities_count_spec.rb
+++ b/ee/spec/requests/api/graphql/project/vulnerability_severities_count_spec.rb
@@ -23,6 +23,7 @@
     stub_licensed_features(security_dashboard: true)
 
     create_list(:vulnerability, 2, :high, :with_issue_links, :with_finding, resolved_on_default_branch: true, project: project)
+      .tap { |vulns| vulns.each { |v| v.vulnerability_read.update!(has_vulnerability_resolution: true) } }
 
     project.add_developer(user)
   end
@@ -103,7 +104,7 @@
     context 'when counting vulnerabilities without resolution' do
       let(:has_resolution) { false }
 
-      it 'counts vulnerabilities with resolution' do
+      it 'counts vulnerabilities without resolution' do
         expect(count_issues).to eq(1)
       end
     end
@@ -117,6 +118,48 @@
     end
   end
 
+  context 'with hasAiResolution filter' do
+    let(:query) do
+      %(
+        query {
+          project(fullPath: "#{project.full_path}") {
+            vulnerabilitySeveritiesCount(hasAiResolution: #{has_ai_resolution}) {
+              high
+            }
+          }
+        }
+      )
+    end
+
+    context 'when counting vulnerabilities without ai resolution' do
+      let(:has_ai_resolution) { false }
+
+      it 'counts vulnerabilities without ai resolution' do
+        expect(count_issues).to eq(1)
+      end
+    end
+
+    context 'when counting vulnerabilities with ai resolution' do
+      let(:has_ai_resolution) { true }
+
+      it 'counts vulnerabilities with ai resolution' do
+        expect(count_issues).to eq(2)
+      end
+    end
+
+    context 'when vulnerability_report_vr_filter FF is disabled' do
+      let(:has_ai_resolution) { true }
+
+      before do
+        stub_feature_flags(vulnerability_report_vr_filter: false)
+      end
+
+      it 'ignores the has_ai_resolution parameter' do
+        expect(count_issues).to eq(3)
+      end
+    end
+  end
+
   def count_issues
     subject.dig('data', 'project', 'vulnerabilitySeveritiesCount', 'high')
   end
-- 
GitLab