From 9bc0e5ddf7094947790d12ca7be336b40b91b331 Mon Sep 17 00:00:00 2001
From: Dmytro Biryukov <dbiryukov@gitlab.com>
Date: Wed, 14 Jun 2023 23:07:39 +0000
Subject: [PATCH] Add GraphQL resolver, query to fetch group environment scopes

---
 .../groups/environment_scopes_finder.rb       | 50 ++++++++++++++
 .../group_environment_scopes_resolver.rb      | 23 +++++++
 ...group_environment_scope_connection_type.rb | 10 +++
 .../types/ci/group_environment_scope_type.rb  | 18 +++++
 app/graphql/types/group_type.rb               |  6 ++
 app/models/ci/group_variable.rb               | 13 ++++
 doc/api/graphql/reference/index.md            | 50 ++++++++++++++
 .../groups/environment_scopes_finder_spec.rb  | 48 +++++++++++++
 .../group_environment_scopes_resolver_spec.rb | 45 ++++++++++++
 .../ci/group_environment_scope_type_spec.rb   | 11 +++
 spec/models/ci/group_variable_spec.rb         | 30 ++++++++
 .../ci/group_environment_scopes_spec.rb       | 68 +++++++++++++++++++
 spec/support/finder_collection_allowlist.yml  |  1 +
 13 files changed, 373 insertions(+)
 create mode 100644 app/finders/groups/environment_scopes_finder.rb
 create mode 100644 app/graphql/resolvers/group_environment_scopes_resolver.rb
 create mode 100644 app/graphql/types/ci/group_environment_scope_connection_type.rb
 create mode 100644 app/graphql/types/ci/group_environment_scope_type.rb
 create mode 100644 spec/finders/groups/environment_scopes_finder_spec.rb
 create mode 100644 spec/graphql/resolvers/group_environment_scopes_resolver_spec.rb
 create mode 100644 spec/graphql/types/ci/group_environment_scope_type_spec.rb
 create mode 100644 spec/requests/api/graphql/ci/group_environment_scopes_spec.rb

diff --git a/app/finders/groups/environment_scopes_finder.rb b/app/finders/groups/environment_scopes_finder.rb
new file mode 100644
index 0000000000000..886be7881ee76
--- /dev/null
+++ b/app/finders/groups/environment_scopes_finder.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+# Groups::EnvironmentsScopesFinder
+#
+# Arguments:
+#   group
+#   params:
+#     search: string
+#
+module Groups
+  class EnvironmentScopesFinder
+    DEFAULT_ENVIRONMENT_SCOPES_LIMIT = 100
+
+    def initialize(group:, params: {})
+      @group = group
+      @params = params
+    end
+
+    EnvironmentScope = Struct.new(:name)
+
+    def execute
+      variables = group.variables
+      variables = by_name(variables)
+      variables = by_search(variables)
+      variables = variables.limit(DEFAULT_ENVIRONMENT_SCOPES_LIMIT)
+      environment_scope_names = variables.environment_scope_names
+      environment_scope_names.map { |environment_scope| EnvironmentScope.new(environment_scope) }
+    end
+
+    private
+
+    attr_reader :group, :params
+
+    def by_name(group_variables)
+      if params[:name].present?
+        group_variables.by_environment_scope(params[:name])
+      else
+        group_variables
+      end
+    end
+
+    def by_search(group_variables)
+      if params[:search].present?
+        group_variables.for_environment_scope_like(params[:search])
+      else
+        group_variables
+      end
+    end
+  end
+end
diff --git a/app/graphql/resolvers/group_environment_scopes_resolver.rb b/app/graphql/resolvers/group_environment_scopes_resolver.rb
new file mode 100644
index 0000000000000..61ccb2eefbba6
--- /dev/null
+++ b/app/graphql/resolvers/group_environment_scopes_resolver.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Resolvers
+  class GroupEnvironmentScopesResolver < BaseResolver
+    type Types::Ci::GroupEnvironmentScopeType.connection_type, null: true
+
+    alias_method :group, :object
+
+    argument :name, GraphQL::Types::String,
+      required: false,
+      description: 'Name of the environment scope.'
+
+    argument :search, GraphQL::Types::String,
+      required: false,
+      description: 'Search query for environment scope name.'
+
+    def resolve(**args)
+      return unless group.present?
+
+      ::Groups::EnvironmentScopesFinder.new(group: group, params: args).execute
+    end
+  end
+end
diff --git a/app/graphql/types/ci/group_environment_scope_connection_type.rb b/app/graphql/types/ci/group_environment_scope_connection_type.rb
new file mode 100644
index 0000000000000..ddbc00d3870cc
--- /dev/null
+++ b/app/graphql/types/ci/group_environment_scope_connection_type.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Types
+  module Ci
+    # rubocop: disable Graphql/AuthorizeTypes
+    class GroupEnvironmentScopeConnectionType < GraphQL::Types::Relay::BaseConnection
+    end
+    # rubocop: enable Graphql/AuthorizeTypes
+  end
+end
diff --git a/app/graphql/types/ci/group_environment_scope_type.rb b/app/graphql/types/ci/group_environment_scope_type.rb
new file mode 100644
index 0000000000000..3a3a5a3f59feb
--- /dev/null
+++ b/app/graphql/types/ci/group_environment_scope_type.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Types
+  module Ci
+    # rubocop: disable Graphql/AuthorizeTypes
+    class GroupEnvironmentScopeType < BaseObject
+      graphql_name 'CiGroupEnvironmentScope'
+      description 'Ci/CD environment scope for a group.'
+
+      connection_type_class(Types::Ci::GroupEnvironmentScopeConnectionType)
+
+      field :name, GraphQL::Types::String,
+        null: true,
+        description: 'Scope name defininig the enviromnments that can use the variable.'
+    end
+    # rubocop: enable Graphql/AuthorizeTypes
+  end
+end
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index 24326c131ce09..5fd6ee948d3d7 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -83,6 +83,12 @@ class GroupType < NamespaceType
           description: 'Merge requests for projects in this group.',
           resolver: Resolvers::GroupMergeRequestsResolver
 
+    field :environment_scopes,
+          Types::Ci::GroupEnvironmentScopeType.connection_type,
+          description: 'Environment scopes of the group.',
+          null: true,
+          resolver: Resolvers::GroupEnvironmentScopesResolver
+
     field :milestones,
           description: 'Milestones of the group.',
           extras: [:lookahead],
diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb
index f04f0d27e512e..5522a01758f0b 100644
--- a/app/models/ci/group_variable.rb
+++ b/app/models/ci/group_variable.rb
@@ -23,6 +23,19 @@ class GroupVariable < Ci::ApplicationRecord
     scope :by_environment_scope, -> (environment_scope) { where(environment_scope: environment_scope) }
     scope :for_groups, ->(group_ids) { where(group_id: group_ids) }
 
+    scope :for_environment_scope_like, -> (query) do
+      top_level = 'LOWER(ci_group_variables.environment_scope) LIKE LOWER(?) || \'%\''
+      search_like = "%#{sanitize_sql_like(query)}%"
+
+      where(top_level, search_like)
+    end
+
+    scope :environment_scope_names, -> do
+      group(:environment_scope)
+      .order(:environment_scope)
+      .pluck(:environment_scope)
+    end
+
     self.limit_name = 'group_ci_variables'
     self.limit_scope = :group
 
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index c16748ec35ffd..cc0e45b76eabb 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -7784,6 +7784,29 @@ The edge type for [`CiGroup`](#cigroup).
 | <a id="cigroupedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
 | <a id="cigroupedgenode"></a>`node` | [`CiGroup`](#cigroup) | The item at the end of the edge. |
 
+#### `CiGroupEnvironmentScopeConnection`
+
+The connection type for [`CiGroupEnvironmentScope`](#cigroupenvironmentscope).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="cigroupenvironmentscopeconnectionedges"></a>`edges` | [`[CiGroupEnvironmentScopeEdge]`](#cigroupenvironmentscopeedge) | A list of edges. |
+| <a id="cigroupenvironmentscopeconnectionnodes"></a>`nodes` | [`[CiGroupEnvironmentScope]`](#cigroupenvironmentscope) | A list of nodes. |
+| <a id="cigroupenvironmentscopeconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
+
+#### `CiGroupEnvironmentScopeEdge`
+
+The edge type for [`CiGroupEnvironmentScope`](#cigroupenvironmentscope).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="cigroupenvironmentscopeedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
+| <a id="cigroupenvironmentscopeedgenode"></a>`node` | [`CiGroupEnvironmentScope`](#cigroupenvironmentscope) | The item at the end of the edge. |
+
 #### `CiGroupVariableConnection`
 
 The connection type for [`CiGroupVariable`](#cigroupvariable).
@@ -12719,6 +12742,16 @@ Represents a deployment freeze window of a project.
 | <a id="cigroupname"></a>`name` | [`String`](#string) | Name of the job group. |
 | <a id="cigroupsize"></a>`size` | [`Int`](#int) | Size of the group. |
 
+### `CiGroupEnvironmentScope`
+
+Ci/CD environment scope for a group.
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="cigroupenvironmentscopename"></a>`name` | [`String`](#string) | Scope name defininig the enviromnments that can use the variable. |
+
 ### `CiGroupVariable`
 
 CI/CD variables for a group.
@@ -15866,6 +15899,23 @@ four standard [pagination arguments](#connection-pagination-arguments):
 | <a id="groupdescendantgroupsowned"></a>`owned` | [`Boolean`](#boolean) | Limit result to groups owned by authenticated user. |
 | <a id="groupdescendantgroupssearch"></a>`search` | [`String`](#string) | Search query for group name or group full path. |
 
+##### `Group.environmentScopes`
+
+Environment scopes of the group.
+
+Returns [`CiGroupEnvironmentScopeConnection`](#cigroupenvironmentscopeconnection).
+
+This field returns a [connection](#connections). It accepts the
+four standard [pagination arguments](#connection-pagination-arguments):
+`before: String`, `after: String`, `first: Int`, `last: Int`.
+
+###### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="groupenvironmentscopesname"></a>`name` | [`String`](#string) | Name of the environment scope. |
+| <a id="groupenvironmentscopessearch"></a>`search` | [`String`](#string) | Search query for environment scope name. |
+
 ##### `Group.epic`
 
 Find a single epic.
diff --git a/spec/finders/groups/environment_scopes_finder_spec.rb b/spec/finders/groups/environment_scopes_finder_spec.rb
new file mode 100644
index 0000000000000..dfa32725e4afc
--- /dev/null
+++ b/spec/finders/groups/environment_scopes_finder_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Groups::EnvironmentScopesFinder, feature_category: :secrets_management do
+  describe '#execute' do
+    let_it_be(:user) { create(:user) }
+    let_it_be(:group) { create(:group, :public) }
+
+    let!(:environment1) { create(:ci_group_variable, group: group, key: 'var1', environment_scope: 'environment1') }
+    let!(:environment2) { create(:ci_group_variable, group: group, key: 'var2', environment_scope: 'environment2') }
+    let!(:environment3) { create(:ci_group_variable, group: group, key: 'var2', environment_scope: 'environment3') }
+    let(:finder) { described_class.new(group: group, params: params) }
+
+    subject { finder.execute }
+
+    context 'with default no arguments' do
+      let(:params) { {} }
+
+      it do
+        expected_result = group.variables.environment_scope_names
+
+        expect(subject.map(&:name))
+          .to match_array(expected_result)
+      end
+    end
+
+    context 'with search' do
+      let(:params) { { search: 'ment1' } }
+
+      it do
+        expected_result = ['environment1']
+
+        expect(subject.map(&:name))
+          .to match_array(expected_result)
+      end
+    end
+
+    context 'with specific name' do
+      let(:params) { { name: 'environment3' } }
+
+      it do
+        expect(subject.map(&:name))
+          .to match_array([environment3.environment_scope])
+      end
+    end
+  end
+end
diff --git a/spec/graphql/resolvers/group_environment_scopes_resolver_spec.rb b/spec/graphql/resolvers/group_environment_scopes_resolver_spec.rb
new file mode 100644
index 0000000000000..71561137356f8
--- /dev/null
+++ b/spec/graphql/resolvers/group_environment_scopes_resolver_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::GroupEnvironmentScopesResolver, feature_category: :secrets_management do
+  include GraphqlHelpers
+
+  let_it_be(:current_user) { create(:user) }
+  let(:group) { create(:group) }
+
+  context "with a group" do
+    let(:expected_environment_scopes) do
+      %w[environment1 environment2 environment3 environment4 environment5 environment6]
+    end
+
+    before do
+      group.add_developer(current_user)
+      expected_environment_scopes.each_with_index do |env, index|
+        create(:ci_group_variable, group: group, key: "var#{index + 1}", environment_scope: env)
+      end
+    end
+
+    describe '#resolve' do
+      it 'finds all environment scopes' do
+        expect(resolve_environment_scopes.map(&:name)).to match_array(
+          expected_environment_scopes
+        )
+      end
+    end
+  end
+
+  context 'without a group' do
+    describe '#resolve' do
+      it 'rails to find any environment scopes' do
+        expect(resolve_environment_scopes.map(&:name)).to match_array(
+          []
+        )
+      end
+    end
+  end
+
+  def resolve_environment_scopes(args = {}, context = { current_user: current_user })
+    resolve(described_class, obj: group, args: args, ctx: context)
+  end
+end
diff --git a/spec/graphql/types/ci/group_environment_scope_type_spec.rb b/spec/graphql/types/ci/group_environment_scope_type_spec.rb
new file mode 100644
index 0000000000000..3e3f52ca4bb16
--- /dev/null
+++ b/spec/graphql/types/ci/group_environment_scope_type_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['CiGroupEnvironmentScope'], feature_category: :secrets_management do
+  specify do
+    expect(described_class).to have_graphql_fields(
+      :name
+    ).at_least
+  end
+end
diff --git a/spec/models/ci/group_variable_spec.rb b/spec/models/ci/group_variable_spec.rb
index a2751b9fb2040..5a8a2b391e179 100644
--- a/spec/models/ci/group_variable_spec.rb
+++ b/spec/models/ci/group_variable_spec.rb
@@ -54,6 +54,36 @@
     it { expect(described_class.for_groups([group.id])).to eq([group_variable]) }
   end
 
+  describe '.for_environment_scope_like' do
+    let_it_be(:group) { create(:group) }
+    let_it_be(:variable1_on_staging1) { create(:ci_group_variable, group: group, environment_scope: 'staging1') }
+    let_it_be(:variable2_on_staging2) { create(:ci_group_variable, group: group, environment_scope: 'staging2') }
+    let_it_be(:variable3_on_production) { create(:ci_group_variable, group: group, environment_scope: 'production') }
+
+    it {
+      expect(described_class.for_environment_scope_like('staging'))
+        .to match_array([variable1_on_staging1, variable2_on_staging2])
+    }
+
+    it {
+      expect(described_class.for_environment_scope_like('production'))
+        .to match_array([variable3_on_production])
+    }
+  end
+
+  describe '.environment_scope_names' do
+    let_it_be(:group) { create(:group) }
+    let_it_be(:variable1_on_staging1) { create(:ci_group_variable, group: group, environment_scope: 'staging1') }
+    let_it_be(:variable2_on_staging2) { create(:ci_group_variable, group: group, environment_scope: 'staging2') }
+    let_it_be(:variable3_on_staging2) { create(:ci_group_variable, group: group, environment_scope: 'staging2') }
+    let_it_be(:variable4_on_production) { create(:ci_group_variable, group: group, environment_scope: 'production') }
+
+    it 'groups and orders' do
+      expect(described_class.environment_scope_names)
+        .to match_array(%w[production staging1 staging2])
+    end
+  end
+
   it_behaves_like 'cleanup by a loose foreign key' do
     let!(:model) { create(:ci_group_variable) }
 
diff --git a/spec/requests/api/graphql/ci/group_environment_scopes_spec.rb b/spec/requests/api/graphql/ci/group_environment_scopes_spec.rb
new file mode 100644
index 0000000000000..13a3a128979ff
--- /dev/null
+++ b/spec/requests/api/graphql/ci/group_environment_scopes_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Query.group(fullPath).environmentScopes', feature_category: :secrets_management do
+  include GraphqlHelpers
+
+  let_it_be(:user) { create(:user) }
+  let_it_be(:group) { create(:group) }
+  let(:expected_environment_scopes) do
+    %w[
+      group1_environment1
+      group1_environment2
+      group2_environment3
+      group2_environment4
+      group2_environment5
+      group2_environment6
+    ]
+  end
+
+  let(:query) do
+    %(
+      query {
+        group(fullPath: "#{group.full_path}") {
+          environmentScopes#{environment_scopes_params} {
+            nodes {
+              name
+            }
+          }
+        }
+      }
+    )
+  end
+
+  before do
+    group.add_developer(user)
+    expected_environment_scopes.each_with_index do |env, index|
+      create(:ci_group_variable, group: group, key: "var#{index + 1}", environment_scope: env)
+    end
+  end
+
+  context 'when query has no parameters' do
+    let(:environment_scopes_params) { "" }
+
+    it 'returns all avaiable environment scopes' do
+      post_graphql(query, current_user: user)
+
+      expect(graphql_data.dig('group', 'environmentScopes', 'nodes')).to eq(
+        expected_environment_scopes.map { |env_scope| { 'name' => env_scope } }
+      )
+    end
+  end
+
+  context 'when query has search parameters' do
+    let(:environment_scopes_params) { "(search: \"group1\")" }
+
+    it 'returns only environment scopes with group1 prefix' do
+      post_graphql(query, current_user: user)
+
+      expect(graphql_data.dig('group', 'environmentScopes', 'nodes')).to eq(
+        [
+          { 'name' => 'group1_environment1' },
+          { 'name' => 'group1_environment2' }
+        ]
+      )
+    end
+  end
+end
diff --git a/spec/support/finder_collection_allowlist.yml b/spec/support/finder_collection_allowlist.yml
index 8fcb4ee7b9c2a..7ac7e88867a84 100644
--- a/spec/support/finder_collection_allowlist.yml
+++ b/spec/support/finder_collection_allowlist.yml
@@ -5,6 +5,7 @@
 # FooFinder  # Reason: It uses a memory backend
 - Namespaces::BilledUsersFinder # Reason: There is no need to have anything else besides the ids is current structure
 - Namespaces::FreeUserCap::UsersFinder # Reason: There is no need to have anything else besides the count
+- Groups::EnvironmentScopesFinder # Reason: There is no need to have anything else besides the simple strucutre with the scope name
 
 # Temporary excludes (aka TODOs)
 # For example:
-- 
GitLab