From 1ab8525fb5b719a27be56637fa2ed617b92607ce Mon Sep 17 00:00:00 2001
From: Rajat Jain <rjain@gitlab.com>
Date: Mon, 25 Apr 2022 14:22:09 +0530
Subject: [PATCH] Add epic board list resolver

- Add top level query to fetch an epic list
Update graphql docs

- Modify list service to take board and list
Modify Boards::BaseItemsListService to use list
from params if present
Modify Boards::Epics::ListService to use board
rom params if present
Add specs

Changelog: added
EE: true
---
 .../boards/base_items_list_service.rb         |  7 +-
 doc/api/graphql/reference/index.md            | 11 +++
 ee/app/graphql/ee/types/query_type.rb         |  4 +
 .../boards/board_list_epics_resolver.rb       |  8 +-
 .../resolvers/boards/epic_list_resolver.rb    | 32 +++++++
 ee/app/graphql/types/boards/epic_list_type.rb | 14 +++-
 ee/app/services/boards/epics/list_service.rb  |  2 +-
 .../boards/epic_list_resolver_spec.rb         | 45 ++++++++++
 ee/spec/graphql/types/query_type_spec.rb      | 13 ++-
 .../graphql/boards/epic_list_query_spec.rb    | 84 +++++++++++++++++++
 .../boards/epics/list_service_spec.rb         | 14 +++-
 .../items_list_service_shared_examples.rb     | 40 ++++++---
 12 files changed, 250 insertions(+), 24 deletions(-)
 create mode 100644 ee/app/graphql/resolvers/boards/epic_list_resolver.rb
 create mode 100644 ee/spec/graphql/resolvers/boards/epic_list_resolver_spec.rb
 create mode 100644 ee/spec/requests/api/graphql/boards/epic_list_query_spec.rb

diff --git a/app/services/boards/base_items_list_service.rb b/app/services/boards/base_items_list_service.rb
index 01fad14d036e..2a9cbb83cc41 100644
--- a/app/services/boards/base_items_list_service.rb
+++ b/app/services/boards/base_items_list_service.rb
@@ -78,12 +78,15 @@ def filter(items)
     end
 
     def list
-      return unless params.key?(:id)
+      return unless params.key?(:id) || params.key?(:list)
 
       strong_memoize(:list) do
         id = params[:id]
+        list = params[:list]
 
-        if board.lists.loaded?
+        if list.present?
+          list
+        elsif board.lists.loaded?
           board.lists.find { |l| l.id == id }
         else
           board.lists.find(id)
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index d07b84cbe69c..8306249eb373 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -145,6 +145,17 @@ Returns [`String!`](#string).
 | ---- | ---- | ----------- |
 | <a id="queryechotext"></a>`text` | [`String!`](#string) | Text to echo back. |
 
+### `Query.epicBoardList`
+
+Returns [`EpicList`](#epiclist).
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="queryepicboardlistepicfilters"></a>`epicFilters` | [`EpicFilters`](#epicfilters) | Filters applied when getting epic metadata in the epic board list. |
+| <a id="queryepicboardlistid"></a>`id` | [`BoardsEpicListID!`](#boardsepiclistid) | Global ID of the list. |
+
 ### `Query.geoNode`
 
 Find a Geo node.
diff --git a/ee/app/graphql/ee/types/query_type.rb b/ee/app/graphql/ee/types/query_type.rb
index 1658bd517e41..cfd4a1de1840 100644
--- a/ee/app/graphql/ee/types/query_type.rb
+++ b/ee/app/graphql/ee/types/query_type.rb
@@ -75,6 +75,10 @@ module QueryType
                    required: false,
                    description: 'Global ID of the Namespace for the monthly CI/CD minutes usage.'
         end
+
+        field :epic_board_list, ::Types::Boards::EpicListType,
+               null: true,
+               resolver: ::Resolvers::Boards::EpicListResolver
       end
 
       def vulnerability(id:)
diff --git a/ee/app/graphql/resolvers/boards/board_list_epics_resolver.rb b/ee/app/graphql/resolvers/boards/board_list_epics_resolver.rb
index c62f8ac21bbc..123dfe502e7c 100644
--- a/ee/app/graphql/resolvers/boards/board_list_epics_resolver.rb
+++ b/ee/app/graphql/resolvers/boards/board_list_epics_resolver.rb
@@ -12,9 +12,13 @@ class BoardListEpicsResolver < BaseResolver
          description: 'Filters applied when selecting epics in the board list.'
 
       def resolve(filters: {}, **args)
-        filter_params = { board_id: list.epic_board.id, id: list.id }.merge(filters)
+        filter_params = { board: list.epic_board, list: list }.merge(filters)
 
-        service = ::Boards::Epics::ListService.new(list.epic_board.group, context[:current_user], filter_params)
+        service = ::Boards::Epics::ListService.new(
+          list.epic_board.group,
+          context[:current_user],
+          filter_params
+        )
 
         offset_pagination(service.execute)
       end
diff --git a/ee/app/graphql/resolvers/boards/epic_list_resolver.rb b/ee/app/graphql/resolvers/boards/epic_list_resolver.rb
new file mode 100644
index 000000000000..f6c8421169e8
--- /dev/null
+++ b/ee/app/graphql/resolvers/boards/epic_list_resolver.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Resolvers
+  module Boards
+    class EpicListResolver < BaseResolver.single
+      include Gitlab::Graphql::Authorize::AuthorizeResource
+      include ::BoardItemFilterable
+
+      type Types::Boards::EpicListType, null: true
+
+      argument :id, ::Types::GlobalIDType[::Boards::EpicList],
+        required: true,
+        loads: Types::Boards::EpicListType,
+        as: :list,
+        description: 'Global ID of the list.'
+
+      argument :epic_filters, ::Types::Boards::BoardEpicInputType,
+        required: false,
+        description: 'Filters applied when getting epic metadata in the epic board list.'
+
+      authorize :read_epic_board_list
+
+      def resolve(list:, epic_filters: {})
+        authorize! list
+
+        context.scoped_set!(:epic_filters, item_filters(epic_filters))
+
+        list
+      end
+    end
+  end
+end
diff --git a/ee/app/graphql/types/boards/epic_list_type.rb b/ee/app/graphql/types/boards/epic_list_type.rb
index 79e90190d348..beb401791a06 100644
--- a/ee/app/graphql/types/boards/epic_list_type.rb
+++ b/ee/app/graphql/types/boards/epic_list_type.rb
@@ -2,7 +2,6 @@
 
 module Types
   module Boards
-    # rubocop: disable Graphql/AuthorizeTypes
     class EpicListType < BaseObject
       graphql_name 'EpicList'
       description 'Represents an epic board list'
@@ -10,6 +9,7 @@ class EpicListType < BaseObject
       include Gitlab::Utils::StrongMemoize
 
       accepts ::Boards::EpicList
+      authorize :read_epic_board_list
 
       alias_method :list, :object
 
@@ -64,9 +64,17 @@ def list_service
       end
 
       def params
-        (context[:epic_filters] || {}).merge(board_id: list.epic_board_id, id: list.id)
+        (context[:epic_filters] || {}).merge(board: list.epic_board, list: list)
+      end
+
+      def title
+        BatchLoader::GraphQL.for(object).batch do |lists, callback|
+          ActiveRecord::Associations::Preloader.new.preload(lists, :label) # rubocop: disable CodeReuse/ActiveRecord
+
+          # all list titles are preloaded at this point
+          lists.each { |list| callback.call(list, list.title) }
+        end
       end
     end
-    # rubocop: enable Graphql/AuthorizeTypes
   end
 end
diff --git a/ee/app/services/boards/epics/list_service.rb b/ee/app/services/boards/epics/list_service.rb
index ceba8181d273..57fedbead33f 100644
--- a/ee/app/services/boards/epics/list_service.rb
+++ b/ee/app/services/boards/epics/list_service.rb
@@ -47,7 +47,7 @@ def exclude_positioned?
       end
 
       def board
-        @board ||= parent.epic_boards.find(params[:board_id])
+        @board ||= params[:board].presence || parent.epic_boards.find(params[:board_id])
       end
 
       def order(items)
diff --git a/ee/spec/graphql/resolvers/boards/epic_list_resolver_spec.rb b/ee/spec/graphql/resolvers/boards/epic_list_resolver_spec.rb
new file mode 100644
index 000000000000..a0e6e9bf05f8
--- /dev/null
+++ b/ee/spec/graphql/resolvers/boards/epic_list_resolver_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::Boards::EpicListResolver do
+  include GraphqlHelpers
+  include Gitlab::Graphql::Laziness
+
+  let_it_be(:guest)         { create(:user) }
+  let_it_be(:unauth_user)   { create(:user) }
+  let_it_be(:group)         { create(:group, :private) }
+  let_it_be(:group_label)   { create(:group_label, group: group, name: 'Development') }
+  let_it_be(:board)         { create(:epic_board, group: group) }
+  let_it_be(:label_list)    { create(:epic_list, epic_board: board, label: group_label) }
+
+  describe '#resolve' do
+    subject { resolve_epic_board_list(args: { id: global_id_of(label_list) }, current_user: current_user) }
+
+    before do
+      stub_licensed_features(epics: true)
+    end
+
+    context 'with unauthorized user' do
+      let(:current_user) { unauth_user }
+
+      it 'raises unauthorized error' do
+        expect { subject }.to raise_error(GraphQL::UnauthorizedError)
+      end
+    end
+
+    context 'when authorized' do
+      let(:current_user) { guest }
+
+      before do
+        group.add_guest(guest)
+      end
+
+      it { is_expected.to eq label_list }
+    end
+  end
+
+  def resolve_epic_board_list(args: {}, current_user: user)
+    force(resolve(described_class, obj: nil, args: args, ctx: { current_user: current_user }))
+  end
+end
diff --git a/ee/spec/graphql/types/query_type_spec.rb b/ee/spec/graphql/types/query_type_spec.rb
index deeccfe8caed..e60017bd4b43 100644
--- a/ee/spec/graphql/types/query_type_spec.rb
+++ b/ee/spec/graphql/types/query_type_spec.rb
@@ -14,7 +14,18 @@
       :subscription_future_entries,
       :vulnerabilities,
       :vulnerabilities_count_by_day,
-      :vulnerability
+      :vulnerability,
+      :epic_board_list
     ).at_least
   end
+
+  describe 'epicBoardList field' do
+    subject { described_class.fields['epicBoardList'] }
+
+    it 'finds an epic board list by its gid' do
+      is_expected.to have_graphql_arguments(:id, :epic_filters)
+      is_expected.to have_graphql_type(Types::Boards::EpicListType)
+      is_expected.to have_graphql_resolver(Resolvers::Boards::EpicListResolver)
+    end
+  end
 end
diff --git a/ee/spec/requests/api/graphql/boards/epic_list_query_spec.rb b/ee/spec/requests/api/graphql/boards/epic_list_query_spec.rb
new file mode 100644
index 000000000000..962e0ec05517
--- /dev/null
+++ b/ee/spec/requests/api/graphql/boards/epic_list_query_spec.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Querying an Epic board list' do
+  include GraphqlHelpers
+
+  let_it_be(:current_user) { create(:user) }
+  let_it_be(:group) { create(:group, :private) }
+  let_it_be(:board) { create(:epic_board, group: group) }
+  let_it_be(:label) { create(:group_label, group: group, name: 'Development') }
+  let_it_be(:list) { create(:epic_list, epic_board: board, label: label) }
+  let_it_be(:epic1) { create(:epic, group: group, labels: [label], title: 'Epic1') }
+  let_it_be(:epic2) { create(:epic, group: group, labels: [label], title: 'Epic2') }
+  let_it_be(:epic3) { create(:epic, group: group, labels: [label], title: 'Epic3') }
+
+  let(:filters) { {} }
+  let(:query) do
+    graphql_query_for(
+      :epic_board_list,
+      { id: list.to_global_id.to_s, epicFilters: filters }, %w[id]
+    )
+  end
+
+  subject { graphql_data['epicBoardList'] }
+
+  before do
+    stub_licensed_features(epics: true)
+    post_graphql(query, current_user: current_user)
+  end
+
+  context 'when the user has access to the epic list' do
+    before_all do
+      group.add_guest(current_user)
+    end
+
+    it_behaves_like 'a working graphql query'
+
+    it { is_expected.to include({ 'id' => list.to_global_id.to_s }) }
+  end
+
+  context 'when the user does not have access to the list' do
+    it { is_expected.to be_nil }
+  end
+
+  context 'when ID argument is missing' do
+    let(:query) do
+      graphql_query_for('epicBoardList', {}, 'title')
+    end
+
+    it 'raises an exception' do
+      expect(graphql_errors).to include(a_hash_including('message' =>
+        "Field 'epicBoardList' is missing required arguments: id"))
+    end
+  end
+
+  context 'when list ID is not found' do
+    let(:query) do
+      graphql_query_for('boardList', { id: "gid://gitlab/List/#{non_existing_record_id}" }, 'title')
+    end
+
+    it { is_expected.to be_nil }
+  end
+
+  it 'does not have an N+1 when querying title' do
+    a, b = create_list(:epic_list, 2, epic_board: board)
+    ctx = { current_user: current_user }
+    group.add_guest(current_user)
+
+    baseline = graphql_query_for(:epic_board_list, { id: global_id_of(a) }, 'title')
+    query = <<~GQL
+      query {
+        a: #{query_graphql_field(:epic_board_list, { id: global_id_of(a) }, 'title')}
+        b: #{query_graphql_field(:epic_board_list, { id: global_id_of(b) }, 'title')}
+      }
+    GQL
+
+    control = ActiveRecord::QueryRecorder.new do
+      run_with_clean_state(baseline, context: ctx)
+    end
+
+    expect { run_with_clean_state(query, context: ctx) }.not_to exceed_query_limit(control)
+  end
+end
diff --git a/ee/spec/services/boards/epics/list_service_spec.rb b/ee/spec/services/boards/epics/list_service_spec.rb
index 83daf2d0ba99..81c4c5ed5a6b 100644
--- a/ee/spec/services/boards/epics/list_service_spec.rb
+++ b/ee/spec/services/boards/epics/list_service_spec.rb
@@ -58,6 +58,12 @@
         .new(group, user, { board_id: board.id, id: list1.id, from_id: list1_epic2.id })
         .execute
     end
+
+    it 'returns opened items when using board param only' do
+      epics = described_class.new(group, user, { board: board }).execute
+
+      expect(epics).to match_array([backlog_epic1])
+    end
   end
 
   describe '#metadata' do
@@ -74,7 +80,7 @@
     context 'with all fields included in the required_fields' do
       let(:fields) { [:total_weight, :epics_count] }
 
-      it 'containes correct data including weight' do
+      it 'contains correct data including weight' do
         expect(subject).to eq({ total_weight: 7, epics_count: 3 })
       end
     end
@@ -82,15 +88,15 @@
     context 'with total_weight not included in the required_fields' do
       let(:fields) { [:epics_count] }
 
-      it 'containes correct data without weight' do
+      it 'contains correct data without weight' do
         expect(subject).to eq({ epics_count: 3 })
       end
     end
 
-    context 'with epics_countr not included in the required_fields' do
+    context 'with epics_count not included in the required_fields' do
       let(:fields) { [:total_weight] }
 
-      it 'containes correct data without weight' do
+      it 'contains correct data without weight' do
         expect(subject).to eq({ total_weight: 7 })
       end
     end
diff --git a/spec/support/shared_examples/services/boards/items_list_service_shared_examples.rb b/spec/support/shared_examples/services/boards/items_list_service_shared_examples.rb
index 9a3a0cc9cc8f..ed05a150f8b0 100644
--- a/spec/support/shared_examples/services/boards/items_list_service_shared_examples.rb
+++ b/spec/support/shared_examples/services/boards/items_list_service_shared_examples.rb
@@ -3,17 +3,17 @@
 RSpec.shared_examples 'items list service' do
   it 'avoids N+1' do
     params = { board_id: board.id }
-    control = ActiveRecord::QueryRecorder.new { described_class.new(parent, user, params).execute }
+    control = ActiveRecord::QueryRecorder.new { list_service(params).execute }
 
     new_list
 
-    expect { described_class.new(parent, user, params).execute }.not_to exceed_query_limit(control)
+    expect { list_service(params).execute }.not_to exceed_query_limit(control)
   end
 
-  it 'returns opened items when list_id is missing' do
+  it 'returns opened items when list_id and list are missing' do
     params = { board_id: board.id }
 
-    items = described_class.new(parent, user, params).execute
+    items = list_service(params).execute
 
     expect(items).to match_array(backlog_items)
   end
@@ -21,7 +21,7 @@
   it 'returns opened items when listing items from Backlog' do
     params = { board_id: board.id, id: backlog.id }
 
-    items = described_class.new(parent, user, params).execute
+    items = list_service(params).execute
 
     expect(items).to match_array(backlog_items)
   end
@@ -29,7 +29,7 @@
   it 'returns opened items that have label list applied when listing items from a label list' do
     params = { board_id: board.id, id: list1.id }
 
-    items = described_class.new(parent, user, params).execute
+    items = list_service(params).execute
 
     expect(items).to match_array(list1_items)
   end
@@ -37,20 +37,24 @@
   it 'returns closed items when listing items from Closed sorted by closed_at in descending order' do
     params = { board_id: board.id, id: closed.id }
 
-    items = described_class.new(parent, user, params).execute
+    items = list_service(params).execute
 
     expect(items).to eq(closed_items)
   end
 
   it 'raises an error if the list does not belong to the board' do
     list = create(list_factory) # rubocop:disable Rails/SaveBang
-    service = described_class.new(parent, user, board_id: board.id, id: list.id)
+    params = { board_id: board.id, id: list.id }
+
+    service = list_service(params)
 
     expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound)
   end
 
-  it 'raises an error if list id is invalid' do
-    service = described_class.new(parent, user, board_id: board.id, id: nil)
+  it 'raises an error if list and list id are invalid or missing' do
+    params = { board_id: board.id, id: nil, list: nil }
+
+    service = list_service(params)
 
     expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound)
   end
@@ -58,8 +62,22 @@
   it 'returns items from all lists if :all_list is used' do
     params = { board_id: board.id, all_lists: true }
 
-    items = described_class.new(parent, user, params).execute
+    items = list_service(params).execute
 
     expect(items).to match_array(all_items)
   end
+
+  it 'returns opened items that have label list applied when using list param' do
+    params = { board_id: board.id, list: list1 }
+
+    items = list_service(params).execute
+
+    expect(items).to match_array(list1_items)
+  end
+
+  def list_service(params)
+    args = [parent, user].push(params)
+
+    described_class.new(*args)
+  end
 end
-- 
GitLab