From 06b3aac84d2d1aa7b60658ba8d5eb927c5bcc8df Mon Sep 17 00:00:00 2001
From: Pedro Pombeiro <noreply@pedro.pombei.ro>
Date: Tue, 18 May 2021 06:30:35 +0000
Subject: [PATCH] Add runners GraphQL query

---
 app/graphql/resolvers/ci/runners_resolver.rb  |  43 ++++++
 app/graphql/types/ci/runner_sort_enum.rb      |  13 ++
 app/graphql/types/query_type.rb               |   6 +
 doc/api/graphql/reference/index.md            |  51 +++++++
 .../resolvers/ci/runners_resolver_spec.rb     | 136 ++++++++++++++++++
 spec/graphql/types/query_type_spec.rb         |   7 +
 spec/requests/api/graphql/ci/runners_spec.rb  | 114 +++++++++++++++
 7 files changed, 370 insertions(+)
 create mode 100644 app/graphql/resolvers/ci/runners_resolver.rb
 create mode 100644 app/graphql/types/ci/runner_sort_enum.rb
 create mode 100644 spec/graphql/resolvers/ci/runners_resolver_spec.rb
 create mode 100644 spec/requests/api/graphql/ci/runners_spec.rb

diff --git a/app/graphql/resolvers/ci/runners_resolver.rb b/app/graphql/resolvers/ci/runners_resolver.rb
new file mode 100644
index 0000000000000..710706325cc91
--- /dev/null
+++ b/app/graphql/resolvers/ci/runners_resolver.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Resolvers
+  module Ci
+    class RunnersResolver < BaseResolver
+      type Types::Ci::RunnerType.connection_type, null: true
+
+      argument :status, ::Types::Ci::RunnerStatusEnum,
+               required: false,
+               description: 'Filter runners by status.'
+
+      argument :type, ::Types::Ci::RunnerTypeEnum,
+               required: false,
+               description: 'Filter runners by type.'
+
+      argument :tag_list, [GraphQL::STRING_TYPE],
+               required: false,
+               description: 'Filter by tags associated with the runner (comma-separated or array).'
+
+      argument :sort, ::Types::Ci::RunnerSortEnum,
+               required: false,
+               description: 'Sort order of results.'
+
+      def resolve(**args)
+        ::Ci::RunnersFinder
+          .new(current_user: current_user, params: runners_finder_params(args))
+          .execute
+      end
+
+      private
+
+      def runners_finder_params(params)
+        {
+          status_status: params[:status]&.to_s,
+          type_type: params[:type],
+          tag_name: params[:tag_list],
+          search: params[:search],
+          sort: params[:sort]&.to_s
+        }.compact
+      end
+    end
+  end
+end
diff --git a/app/graphql/types/ci/runner_sort_enum.rb b/app/graphql/types/ci/runner_sort_enum.rb
new file mode 100644
index 0000000000000..550e870316a75
--- /dev/null
+++ b/app/graphql/types/ci/runner_sort_enum.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Types
+  module Ci
+    class RunnerSortEnum < BaseEnum
+      graphql_name 'CiRunnerSort'
+      description 'Values for sorting runners'
+
+      value 'CONTACTED_ASC', 'Ordered by contacted_at in ascending order.', value: :contacted_asc
+      value 'CREATED_DESC', 'Ordered by created_date in descending order.', value: :created_date
+    end
+  end
+end
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 57e5df51aa8df..8b7b9f0107be6 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -119,6 +119,12 @@ class QueryType < ::Types::BaseObject
           description: "Find a runner.",
           feature_flag: :runner_graphql_query
 
+    field :runners, Types::Ci::RunnerType.connection_type,
+          null: true,
+          resolver: Resolvers::Ci::RunnersResolver,
+          description: "Find runners visible to the current user.",
+          feature_flag: :runner_graphql_query
+
     field :ci_config, resolver: Resolvers::Ci::ConfigResolver, complexity: 126 # AUTHENTICATED_COMPLEXITY / 2 + 1
 
     def design_management
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index a3f6c45ba7588..d7727217dac9f 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -322,6 +322,25 @@ Returns [`RunnerSetup`](#runnersetup).
 | <a id="queryrunnersetupplatform"></a>`platform` | [`String!`](#string) | Platform to generate the instructions for. |
 | <a id="queryrunnersetupprojectid"></a>`projectId` **{warning-solid}** | [`ProjectID`](#projectid) | **Deprecated** in 13.11. No longer used. |
 
+### `Query.runners`
+
+Find runners visible to the current user. Available only when feature flag `runner_graphql_query` is enabled.
+
+Returns [`CiRunnerConnection`](#cirunnerconnection).
+
+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="queryrunnerssort"></a>`sort` | [`CiRunnerSort`](#cirunnersort) | Sort order of results. |
+| <a id="queryrunnersstatus"></a>`status` | [`CiRunnerStatus`](#cirunnerstatus) | Filter runners by status. |
+| <a id="queryrunnerstaglist"></a>`tagList` | [`[String!]`](#string) | Filter by tags associated with the runner (comma-separated or array). |
+| <a id="queryrunnerstype"></a>`type` | [`CiRunnerType`](#cirunnertype) | Filter runners by type. |
+
 ### `Query.snippets`
 
 Find Snippets visible to the current user.
@@ -4600,6 +4619,29 @@ The edge type for [`CiJob`](#cijob).
 | <a id="cijobedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
 | <a id="cijobedgenode"></a>`node` | [`CiJob`](#cijob) | The item at the end of the edge. |
 
+#### `CiRunnerConnection`
+
+The connection type for [`CiRunner`](#cirunner).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="cirunnerconnectionedges"></a>`edges` | [`[CiRunnerEdge]`](#cirunneredge) | A list of edges. |
+| <a id="cirunnerconnectionnodes"></a>`nodes` | [`[CiRunner]`](#cirunner) | A list of nodes. |
+| <a id="cirunnerconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
+
+#### `CiRunnerEdge`
+
+The edge type for [`CiRunner`](#cirunner).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="cirunneredgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
+| <a id="cirunneredgenode"></a>`node` | [`CiRunner`](#cirunner) | The item at the end of the edge. |
+
 #### `CiStageConnection`
 
 The connection type for [`CiStage`](#cistage).
@@ -13647,6 +13689,15 @@ Values for YAML processor result.
 | <a id="cirunneraccesslevelnot_protected"></a>`NOT_PROTECTED` | A runner that is not protected. |
 | <a id="cirunneraccesslevelref_protected"></a>`REF_PROTECTED` | A runner that is ref protected. |
 
+### `CiRunnerSort`
+
+Values for sorting runners.
+
+| Value | Description |
+| ----- | ----------- |
+| <a id="cirunnersortcontacted_asc"></a>`CONTACTED_ASC` | Ordered by contacted_at in ascending order. |
+| <a id="cirunnersortcreated_desc"></a>`CREATED_DESC` | Ordered by created_date in descending order. |
+
 ### `CiRunnerStatus`
 
 | Value | Description |
diff --git a/spec/graphql/resolvers/ci/runners_resolver_spec.rb b/spec/graphql/resolvers/ci/runners_resolver_spec.rb
new file mode 100644
index 0000000000000..006d6785506d7
--- /dev/null
+++ b/spec/graphql/resolvers/ci/runners_resolver_spec.rb
@@ -0,0 +1,136 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::Ci::RunnersResolver do
+  include GraphqlHelpers
+
+  let_it_be(:user) { create_default(:user, :admin) }
+  let_it_be(:group) { create(:group, :public) }
+  let_it_be(:project) { create(:project, :repository, :public) }
+
+  let_it_be(:inactive_project_runner) do
+    create(:ci_runner, :project, projects: [project], active: false, contacted_at: 1.minute.ago, tag_list: %w(project_runner))
+  end
+
+  let_it_be(:offline_project_runner) do
+    create(:ci_runner, :project, projects: [project], contacted_at: 1.day.ago, tag_list: %w(project_runner active_runner))
+  end
+
+  let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group], contacted_at: 1.second.ago) }
+  let_it_be(:instance_runner) { create(:ci_runner, :instance, contacted_at: 2.minutes.ago, tag_list: %w(instance_runner active_runner)) }
+
+  describe '#resolve' do
+    subject { resolve(described_class, ctx: { current_user: user }, args: args).items.to_a }
+
+    let(:args) do
+      {}
+    end
+
+    context 'without sort' do
+      it 'returns all the runners' do
+        is_expected.to contain_exactly(inactive_project_runner, offline_project_runner, group_runner, instance_runner)
+      end
+    end
+
+    context 'with a sort argument' do
+      context "set to :contacted_asc" do
+        let(:args) do
+          { sort: :contacted_asc }
+        end
+
+        it { is_expected.to eq([offline_project_runner, instance_runner, inactive_project_runner, group_runner]) }
+      end
+
+      context "set to :created_date" do
+        let(:args) do
+          { sort: :created_date }
+        end
+
+        it { is_expected.to eq([instance_runner, group_runner, offline_project_runner, inactive_project_runner]) }
+      end
+    end
+
+    context 'when type is filtered' do
+      let(:args) do
+        { type: runner_type.to_s }
+      end
+
+      context 'to instance runners' do
+        let(:runner_type) { :instance_type }
+
+        it 'returns the instance runner' do
+          is_expected.to contain_exactly(instance_runner)
+        end
+      end
+
+      context 'to group runners' do
+        let(:runner_type) { :group_type }
+
+        it 'returns the group runner' do
+          is_expected.to contain_exactly(group_runner)
+        end
+      end
+
+      context 'to project runners' do
+        let(:runner_type) { :project_type }
+
+        it 'returns the project runner' do
+          is_expected.to contain_exactly(inactive_project_runner, offline_project_runner)
+        end
+      end
+    end
+
+    context 'when status is filtered' do
+      let(:args) do
+        { status: runner_status.to_s }
+      end
+
+      context 'to active runners' do
+        let(:runner_status) { :active }
+
+        it 'returns the instance and group runners' do
+          is_expected.to contain_exactly(offline_project_runner, group_runner, instance_runner)
+        end
+      end
+
+      context 'to offline runners' do
+        let(:runner_status) { :offline }
+
+        it 'returns the offline project runner' do
+          is_expected.to contain_exactly(offline_project_runner)
+        end
+      end
+    end
+
+    context 'when tag list is filtered' do
+      let(:args) do
+        { tag_list: tag_list }
+      end
+
+      context 'with "project_runner" tag' do
+        let(:tag_list) { ['project_runner'] }
+
+        it 'returns the project_runner runners' do
+          is_expected.to contain_exactly(offline_project_runner, inactive_project_runner)
+        end
+      end
+
+      context 'with "project_runner" and "active_runner" tags as comma-separated string' do
+        let(:tag_list) { ['project_runner,active_runner'] }
+
+        it 'returns the offline_project_runner runner' do
+          is_expected.to contain_exactly(offline_project_runner)
+        end
+      end
+
+      context 'with "active_runner" and "instance_runner" tags as array' do
+        let(:tag_list) { %w[instance_runner active_runner] }
+
+        it 'returns the offline_project_runner runner' do
+          is_expected.to contain_exactly(instance_runner)
+        end
+      end
+    end
+  end
+end
diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb
index f0e80fa8f147a..9a8f2090cc172 100644
--- a/spec/graphql/types/query_type_spec.rb
+++ b/spec/graphql/types/query_type_spec.rb
@@ -25,6 +25,7 @@
       usage_trends_measurements
       runner_platforms
       runner
+      runners
     ]
 
     expect(described_class).to have_graphql_fields(*expected_fields).at_least
@@ -91,6 +92,12 @@
     it { is_expected.to have_graphql_type(Types::Ci::RunnerType) }
   end
 
+  describe 'runners field' do
+    subject { described_class.fields['runners'] }
+
+    it { is_expected.to have_graphql_type(Types::Ci::RunnerType.connection_type) }
+  end
+
   describe 'runner_platforms field' do
     subject { described_class.fields['runnerPlatforms'] }
 
diff --git a/spec/requests/api/graphql/ci/runners_spec.rb b/spec/requests/api/graphql/ci/runners_spec.rb
new file mode 100644
index 0000000000000..778fe5b129e59
--- /dev/null
+++ b/spec/requests/api/graphql/ci/runners_spec.rb
@@ -0,0 +1,114 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe 'Query.runners' do
+  include GraphqlHelpers
+
+  let_it_be(:current_user) { create_default(:user, :admin) }
+
+  describe 'Query.runners' do
+    let_it_be(:project) { create(:project, :repository, :public) }
+    let_it_be(:instance_runner) { create(:ci_runner, :instance, version: 'abc', revision: '123', description: 'Instance runner', ip_address: '127.0.0.1') }
+    let_it_be(:project_runner) { create(:ci_runner, :project, active: false, version: 'def', revision: '456', description: 'Project runner', projects: [project], ip_address: '127.0.0.1') }
+
+    let(:runners_graphql_data) { graphql_data['runners'] }
+
+    let(:params) { {} }
+
+    let(:fields) do
+      <<~QUERY
+        nodes {
+          #{all_graphql_fields_for('CiRunner')}
+        }
+      QUERY
+    end
+
+    let(:query) do
+      %(
+        query {
+          runners(type:#{runner_type},status:#{status}) {
+            #{fields}
+          }
+        }
+      )
+    end
+
+    before do
+      post_graphql(query, current_user: current_user)
+    end
+
+    shared_examples 'a working graphql query returning expected runner' do
+      it_behaves_like 'a working graphql query'
+
+      it 'returns expected runner' do
+        expect(runners_graphql_data['nodes'].map { |n| n['id'] }).to contain_exactly(expected_runner.to_global_id.to_s)
+      end
+    end
+
+    context 'runner_type is INSTANCE_TYPE and status is ACTIVE' do
+      let(:runner_type) { 'INSTANCE_TYPE' }
+      let(:status) { 'ACTIVE' }
+
+      let!(:expected_runner) { instance_runner }
+
+      it_behaves_like 'a working graphql query returning expected runner'
+    end
+
+    context 'runner_type is PROJECT_TYPE and status is NOT_CONNECTED' do
+      let(:runner_type) { 'PROJECT_TYPE' }
+      let(:status) { 'NOT_CONNECTED' }
+
+      let!(:expected_runner) { project_runner }
+
+      it_behaves_like 'a working graphql query returning expected runner'
+    end
+  end
+
+  describe 'pagination' do
+    let(:data_path) { [:runners] }
+
+    def pagination_query(params)
+      graphql_query_for(:runners, params, "#{page_info} nodes { id }")
+    end
+
+    def pagination_results_data(runners)
+      runners.map { |runner| GitlabSchema.parse_gid(runner['id'], expected_type: ::Ci::Runner).model_id.to_i }
+    end
+
+    let_it_be(:runners) do
+      common_args = {
+        version: 'abc',
+        revision: '123',
+        ip_address: '127.0.0.1'
+      }
+
+      [
+        create(:ci_runner, :instance, created_at: 4.days.ago, contacted_at: 3.days.ago, **common_args),
+        create(:ci_runner, :instance, created_at: 30.hours.ago, contacted_at: 1.day.ago, **common_args),
+        create(:ci_runner, :instance, created_at: 1.day.ago, contacted_at: 1.hour.ago, **common_args),
+        create(:ci_runner, :instance, created_at: 2.days.ago, contacted_at: 2.days.ago, **common_args),
+        create(:ci_runner, :instance, created_at: 3.days.ago, contacted_at: 1.second.ago, **common_args)
+      ]
+    end
+
+    context 'when sorted by contacted_at ascending' do
+      let(:ordered_runners) { runners.sort_by(&:contacted_at) }
+
+      it_behaves_like 'sorted paginated query' do
+        let(:sort_param)       { :CONTACTED_ASC }
+        let(:first_param)      { 2 }
+        let(:expected_results) { ordered_runners.map(&:id) }
+      end
+    end
+
+    context 'when sorted by created_at' do
+      let(:ordered_runners) { runners.sort_by(&:created_at).reverse }
+
+      it_behaves_like 'sorted paginated query' do
+        let(:sort_param)       { :CREATED_DESC }
+        let(:first_param)      { 2 }
+        let(:expected_results) { ordered_runners.map(&:id) }
+      end
+    end
+  end
+end
-- 
GitLab