From 7f363d2e204776bfa310ed3b07e76e7de9eedc12 Mon Sep 17 00:00:00 2001
From: Brett Walker <bwalker@gitlab.com>
Date: Tue, 12 Dec 2023 17:12:30 +0000
Subject: [PATCH] Add support for include_descendants

for group milestone REST API query

Changelog: added
---
 .../resolvers/group_milestones_resolver.rb    |  33 +----
 .../resolvers/project_milestones_resolver.rb  |   9 +-
 doc/api/group_milestones.md                   |   3 +-
 .../milestones/group_project_params.rb        |  54 +++++++++
 lib/api/group_milestones.rb                   |   2 +
 lib/api/milestone_responses.rb                |  23 +---
 .../group_milestones_resolver_spec.rb         |  73 +----------
 spec/requests/api/graphql/milestone_spec.rb   |  14 +++
 spec/requests/api/group_milestones_spec.rb    | 114 ++++++++++--------
 .../milestones_shared_examples.rb             |  83 +++++++++++++
 10 files changed, 230 insertions(+), 178 deletions(-)
 create mode 100644 lib/api/concerns/milestones/group_project_params.rb
 create mode 100644 spec/support/shared_examples/requests/api/graphql_rest/milestones_shared_examples.rb

diff --git a/app/graphql/resolvers/group_milestones_resolver.rb b/app/graphql/resolvers/group_milestones_resolver.rb
index 9242be7f684a4..d9a664b6ec2c4 100644
--- a/app/graphql/resolvers/group_milestones_resolver.rb
+++ b/app/graphql/resolvers/group_milestones_resolver.rb
@@ -2,6 +2,8 @@
 
 module Resolvers
   class GroupMilestonesResolver < MilestonesResolver
+    include ::API::Concerns::Milestones::GroupProjectParams
+
     argument :include_ancestors, GraphQL::Types::Boolean,
              required: false,
              description: 'Include milestones from all parent groups.'
@@ -14,36 +16,7 @@ class GroupMilestonesResolver < MilestonesResolver
     private
 
     def parent_id_parameters(args)
-      include_ancestors = args[:include_ancestors].present?
-      include_descendants = args[:include_descendants].present?
-      return { group_ids: parent.id } unless include_ancestors || include_descendants
-
-      group_ids = if include_ancestors && include_descendants
-                    parent.self_and_hierarchy
-                  elsif include_ancestors
-                    parent.self_and_ancestors
-                  else
-                    parent.self_and_descendants
-                  end
-
-      project_ids = if include_descendants
-                      group_projects.with_issues_or_mrs_available_for_user(current_user)
-                    else
-                      nil
-                    end
-
-      {
-        group_ids: group_ids.public_or_visible_to_user(current_user).select(:id),
-        project_ids: project_ids
-      }
-    end
-
-    def group_projects
-      GroupProjectsFinder.new(
-        group: parent,
-        current_user: current_user,
-        options: { include_subgroups: true }
-      ).execute
+      group_finder_params(parent, args)
     end
 
     def preloads
diff --git a/app/graphql/resolvers/project_milestones_resolver.rb b/app/graphql/resolvers/project_milestones_resolver.rb
index cb4e9a5cdf735..0a078836a6a77 100644
--- a/app/graphql/resolvers/project_milestones_resolver.rb
+++ b/app/graphql/resolvers/project_milestones_resolver.rb
@@ -2,6 +2,8 @@
 
 module Resolvers
   class ProjectMilestonesResolver < MilestonesResolver
+    include ::API::Concerns::Milestones::GroupProjectParams
+
     argument :include_ancestors, GraphQL::Types::Boolean,
              required: false,
              description: "Also return milestones in the project's parent group and its ancestors."
@@ -11,12 +13,7 @@ class ProjectMilestonesResolver < MilestonesResolver
     private
 
     def parent_id_parameters(args)
-      return { project_ids: parent.id } unless args[:include_ancestors].present? && parent.group.present?
-
-      {
-        group_ids: parent.group.self_and_ancestors.select(:id),
-        project_ids: parent.id
-      }
+      project_finder_params(parent, args)
     end
   end
 end
diff --git a/doc/api/group_milestones.md b/doc/api/group_milestones.md
index 2552626f6c623..57994e069a57a 100644
--- a/doc/api/group_milestones.md
+++ b/doc/api/group_milestones.md
@@ -35,7 +35,8 @@ Parameters:
 | `title`                     | string | no | Return only the milestones having the given `title` |
 | `search`                    | string | no | Return only milestones with a title or description matching the provided string |
 | `include_parent_milestones` | boolean | no | [Deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/433298) in GitLab 16.7. Use `include_ancestors` instead. |
-| `include_ancestors`         | boolean | no | Include milestones from all parent groups. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/196066) in GitLab 13.4. |
+| `include_ancestors`         | boolean | no | Include milestones for all parent groups. |
+| `include_descendants`       | boolean | no | Include milestones for group and its descendants. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/421030) in GitLab 16.7. |
 | `updated_before`            | datetime | no | Return only milestones updated before the given datetime. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`). Introduced in GitLab 15.10 |
 | `updated_after`             | datetime | no | Return only milestones updated after the given datetime. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`). Introduced in GitLab 15.10 |
 
diff --git a/lib/api/concerns/milestones/group_project_params.rb b/lib/api/concerns/milestones/group_project_params.rb
new file mode 100644
index 0000000000000..72d07d7dcdb29
--- /dev/null
+++ b/lib/api/concerns/milestones/group_project_params.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+# This DRYs up some methods used by both the GraphQL and REST milestone APIs
+module API
+  module Concerns
+    module Milestones
+      module GroupProjectParams
+        extend ActiveSupport::Concern
+
+        private
+
+        def project_finder_params(parent, params)
+          return { project_ids: parent.id } unless params[:include_ancestors].present? && parent.group.present?
+
+          {
+            group_ids: parent.group.self_and_ancestors.select(:id),
+            project_ids: parent.id
+          }
+        end
+
+        def group_finder_params(parent, params)
+          include_ancestors = params[:include_ancestors].present?
+          include_descendants = params[:include_descendants].present?
+          return { group_ids: parent.id } unless include_ancestors || include_descendants
+
+          group_ids = if include_ancestors && include_descendants
+                        parent.self_and_hierarchy
+                      elsif include_ancestors
+                        parent.self_and_ancestors
+                      else
+                        parent.self_and_descendants
+                      end
+
+          if include_descendants
+            project_ids = group_projects(parent).with_issues_or_mrs_available_for_user(current_user)
+          end
+
+          {
+            group_ids: group_ids.public_or_visible_to_user(current_user).select(:id),
+            project_ids: project_ids
+          }
+        end
+
+        def group_projects(parent)
+          GroupProjectsFinder.new(
+            group: parent,
+            current_user: current_user,
+            options: { include_subgroups: true }
+          ).execute
+        end
+      end
+    end
+  end
+end
diff --git a/lib/api/group_milestones.rb b/lib/api/group_milestones.rb
index 0096e466bef9d..55c5ddfe5570f 100644
--- a/lib/api/group_milestones.rb
+++ b/lib/api/group_milestones.rb
@@ -19,6 +19,8 @@ class GroupMilestones < ::API::Base
       end
       params do
         use :list_params
+        optional :include_descendants, type: Grape::API::Boolean,
+          desc: 'Include milestones from all subgroups and subprojects'
       end
       get ":id/milestones" do
         list_milestones_for(user_group)
diff --git a/lib/api/milestone_responses.rb b/lib/api/milestone_responses.rb
index 85493dfc19809..3ef30a5fcd38c 100644
--- a/lib/api/milestone_responses.rb
+++ b/lib/api/milestone_responses.rb
@@ -6,6 +6,8 @@ module MilestoneResponses
 
     included do
       helpers do
+        include ::API::Concerns::Milestones::GroupProjectParams
+
         params :optional_params do
           optional :description, type: String, desc: 'The description of the milestone'
           optional :due_date, type: String, desc: 'The due date of the milestone. The ISO 8601 date format (%Y-%m-%d)'
@@ -90,12 +92,10 @@ def milestone_issuables_for(parent, type)
         end
 
         def parent_finder_params(parent)
-          include_parent = params[:include_ancestors].present?
-
           if parent.is_a?(Project)
-            { project_ids: parent.id, group_ids: (include_parent ? project_group_ids(parent) : nil) }
+            project_finder_params(parent, params)
           else
-            { group_ids: (include_parent ? group_and_ancestor_ids(parent) : parent.id) }
+            group_finder_params(parent, params)
           end
         end
 
@@ -116,21 +116,6 @@ def get_finder_and_entity(type)
             [MergeRequestsFinder, Entities::MergeRequestBasic]
           end
         end
-
-        def project_group_ids(parent)
-          group = parent.group
-          return unless group.present?
-
-          group.self_and_ancestors.select(:id)
-        end
-
-        def group_and_ancestor_ids(group)
-          return unless group.present?
-
-          group.self_and_ancestors
-            .public_or_visible_to_user(current_user)
-            .select(:id)
-        end
       end
     end
   end
diff --git a/spec/graphql/resolvers/group_milestones_resolver_spec.rb b/spec/graphql/resolvers/group_milestones_resolver_spec.rb
index b9b8ef1870beb..e9caf91ecb702 100644
--- a/spec/graphql/resolvers/group_milestones_resolver_spec.rb
+++ b/spec/graphql/resolvers/group_milestones_resolver_spec.rb
@@ -102,76 +102,7 @@ def args(**arguments)
       end
     end
 
-    context 'when including descendant milestones in a public group' do
-      let_it_be(:group) { create(:group, :public) }
-
-      let(:args) { { include_descendants: true } }
-
-      it 'finds milestones only in accessible projects and groups' do
-        accessible_group = create(:group, :private, parent: group)
-        accessible_project = create(:project, group: accessible_group)
-        accessible_group.add_developer(current_user)
-        inaccessible_group = create(:group, :private, parent: group)
-        inaccessible_project = create(:project, :private, group: group)
-        milestone1 = create(:milestone, group: group)
-        milestone2 = create(:milestone, group: accessible_group)
-        milestone3 = create(:milestone, project: accessible_project)
-        create(:milestone, group: inaccessible_group)
-        create(:milestone, project: inaccessible_project)
-
-        expect(resolve_group_milestones(args: args)).to match_array([milestone1, milestone2, milestone3])
-      end
-    end
-
-    describe 'include_descendants and include_ancestors' do
-      let_it_be(:parent_group) { create(:group, :public) }
-      let_it_be(:group) { create(:group, :public, parent: parent_group) }
-      let_it_be(:accessible_group) { create(:group, :private, parent: group) }
-      let_it_be(:accessible_project) { create(:project, group: accessible_group) }
-      let_it_be(:inaccessible_group) { create(:group, :private, parent: group) }
-      let_it_be(:inaccessible_project) { create(:project, :private, group: group) }
-      let_it_be(:milestone1) { create(:milestone, group: group) }
-      let_it_be(:milestone2) { create(:milestone, group: accessible_group) }
-      let_it_be(:milestone3) { create(:milestone, project: accessible_project) }
-      let_it_be(:milestone4) { create(:milestone, group: inaccessible_group) }
-      let_it_be(:milestone5) { create(:milestone, project: inaccessible_project) }
-      let_it_be(:milestone6) { create(:milestone, group: parent_group) }
-
-      before do
-        accessible_group.add_developer(current_user)
-      end
-
-      context 'when including neither ancestor or descendant milestones in a public group' do
-        let(:args) { {} }
-
-        it 'finds milestones only in accessible projects and groups' do
-          expect(resolve_group_milestones(args: args)).to match_array([milestone1])
-        end
-      end
-
-      context 'when including descendant milestones in a public group' do
-        let(:args) { { include_descendants: true } }
-
-        it 'finds milestones only in accessible projects and groups' do
-          expect(resolve_group_milestones(args: args)).to match_array([milestone1, milestone2, milestone3])
-        end
-      end
-
-      context 'when including ancestor milestones in a public group' do
-        let(:args) { { include_ancestors: true } }
-
-        it 'finds milestones only in accessible projects and groups' do
-          expect(resolve_group_milestones(args: args)).to match_array([milestone1, milestone6])
-        end
-      end
-
-      context 'when including both ancestor or descendant milestones in a public group' do
-        let(:args) { { include_descendants: true, include_ancestors: true } }
-
-        it 'finds milestones only in accessible projects and groups' do
-          expect(resolve_group_milestones(args: args)).to match_array([milestone1, milestone2, milestone3, milestone6])
-        end
-      end
-    end
+    # testing for include_descendants and include_ancestors moved into
+    # `spec/requests/api/graphql/milestone_spec.rb`
   end
 end
diff --git a/spec/requests/api/graphql/milestone_spec.rb b/spec/requests/api/graphql/milestone_spec.rb
index 2cea9fd0408dd..0dc2eabc3e1d2 100644
--- a/spec/requests/api/graphql/milestone_spec.rb
+++ b/spec/requests/api/graphql/milestone_spec.rb
@@ -151,4 +151,18 @@
       end
     end
   end
+
+  context 'for common GraphQL/REST' do
+    it_behaves_like 'group milestones including ancestors and descendants'
+
+    def query_group_milestone_ids(params)
+      query = graphql_query_for('group', { 'fullPath' => group.full_path },
+        query_graphql_field('milestones', params, query_graphql_path([:nodes], :id))
+      )
+
+      post_graphql(query, current_user: current_user)
+
+      graphql_data_at(:group, :milestones, :nodes).pluck('id').map { |gid| GlobalID.parse(gid).model_id.to_i }
+    end
+  end
 end
diff --git a/spec/requests/api/group_milestones_spec.rb b/spec/requests/api/group_milestones_spec.rb
index 2b17ce1d40f86..82a4311f7d0a7 100644
--- a/spec/requests/api/group_milestones_spec.rb
+++ b/spec/requests/api/group_milestones_spec.rb
@@ -30,91 +30,103 @@
   it_behaves_like 'group and project milestones', "/groups/:id/milestones"
 
   describe 'GET /groups/:id/milestones' do
-    let_it_be(:ancestor_group) { create(:group, :private) }
-    let_it_be(:ancestor_group_milestone) { create(:milestone, group: ancestor_group, updated_at: 2.days.ago) }
+    context 'for REST only' do
+      let_it_be(:ancestor_group) { create(:group, :private) }
+      let_it_be(:ancestor_group_milestone) { create(:milestone, group: ancestor_group, updated_at: 2.days.ago) }
 
-    before_all do
-      group.update!(parent: ancestor_group)
-    end
+      before_all do
+        group.update!(parent: ancestor_group)
+      end
 
-    context 'when include_ancestors is true' do
-      let(:params) { { include_ancestors: true } }
+      context 'when include_ancestors is true' do
+        let(:params) { { include_ancestors: true } }
 
-      context 'when user has access to ancestor groups' do
-        let(:milestones) { [ancestor_group_milestone, milestone, closed_milestone] }
+        context 'when user has access to ancestor groups' do
+          let(:milestones) { [ancestor_group_milestone, milestone, closed_milestone] }
 
-        before do
-          ancestor_group.add_guest(user)
-          group.add_guest(user)
-        end
+          before do
+            ancestor_group.add_guest(user)
+            group.add_guest(user)
+          end
 
-        it_behaves_like 'listing all milestones'
+          it_behaves_like 'listing all milestones'
 
-        context 'when deprecated include_parent_milestones is true' do
-          let(:params) { { include_parent_milestones: true } }
+          context 'when deprecated include_parent_milestones is true' do
+            let(:params) { { include_parent_milestones: true } }
 
-          it_behaves_like 'listing all milestones'
-        end
+            it_behaves_like 'listing all milestones'
+          end
 
-        context 'when both include_parent_milestones and include_ancestors are specified' do
-          let(:params) { { include_ancestors: true, include_parent_milestones: true } }
+          context 'when both include_parent_milestones and include_ancestors are specified' do
+            let(:params) { { include_ancestors: true, include_parent_milestones: true } }
 
-          it 'returns 400' do
-            get api(route, user), params: params
+            it 'returns 400' do
+              get api(route, user), params: params
 
-            expect(response).to have_gitlab_http_status(:bad_request)
+              expect(response).to have_gitlab_http_status(:bad_request)
+            end
           end
-        end
 
-        context 'when iids param is present' do
-          let(:params) { { include_ancestors: true, iids: [milestone.iid] } }
+          context 'when iids param is present' do
+            let(:params) { { include_ancestors: true, iids: [milestone.iid] } }
 
-          it_behaves_like 'listing all milestones'
-        end
+            it_behaves_like 'listing all milestones'
+          end
 
-        context 'when updated_before param is present' do
-          let(:params) { { updated_before: 1.day.ago.iso8601, include_ancestors: true } }
+          context 'when updated_before param is present' do
+            let(:params) { { updated_before: 1.day.ago.iso8601, include_ancestors: true } }
 
-          it_behaves_like 'listing all milestones' do
-            let(:milestones) { [ancestor_group_milestone, milestone] }
+            it_behaves_like 'listing all milestones' do
+              let(:milestones) { [ancestor_group_milestone, milestone] }
+            end
+          end
+
+          context 'when updated_after param is present' do
+            let(:params) { { updated_after: 1.day.ago.iso8601, include_ancestors: true } }
+
+            it_behaves_like 'listing all milestones' do
+              let(:milestones) { [closed_milestone] }
+            end
           end
         end
 
-        context 'when updated_after param is present' do
-          let(:params) { { updated_after: 1.day.ago.iso8601, include_ancestors: true } }
+        context 'when user has no access to ancestor groups' do
+          let(:user) { create(:user) }
+
+          before do
+            group.add_guest(user)
+          end
 
           it_behaves_like 'listing all milestones' do
-            let(:milestones) { [closed_milestone] }
+            let(:milestones) { [milestone, closed_milestone] }
           end
         end
       end
 
-      context 'when user has no access to ancestor groups' do
-        let(:user) { create(:user) }
-
-        before do
-          group.add_guest(user)
-        end
+      context 'when updated_before param is present' do
+        let(:params) { { updated_before: 1.day.ago.iso8601 } }
 
         it_behaves_like 'listing all milestones' do
-          let(:milestones) { [milestone, closed_milestone] }
+          let(:milestones) { [milestone] }
         end
       end
-    end
 
-    context 'when updated_before param is present' do
-      let(:params) { { updated_before: 1.day.ago.iso8601 } }
+      context 'when updated_after param is present' do
+        let(:params) { { updated_after: 1.day.ago.iso8601 } }
 
-      it_behaves_like 'listing all milestones' do
-        let(:milestones) { [milestone] }
+        it_behaves_like 'listing all milestones' do
+          let(:milestones) { [closed_milestone] }
+        end
       end
     end
 
-    context 'when updated_after param is present' do
-      let(:params) { { updated_after: 1.day.ago.iso8601 } }
+    context 'for common GraphQL/REST' do
+      it_behaves_like 'group milestones including ancestors and descendants'
+
+      def query_group_milestone_ids(params)
+        get api(route, current_user), params: params
 
-      it_behaves_like 'listing all milestones' do
-        let(:milestones) { [closed_milestone] }
+        json_response.pluck('id')
       end
     end
   end
diff --git a/spec/support/shared_examples/requests/api/graphql_rest/milestones_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql_rest/milestones_shared_examples.rb
new file mode 100644
index 0000000000000..8e147f43091af
--- /dev/null
+++ b/spec/support/shared_examples/requests/api/graphql_rest/milestones_shared_examples.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+# Examples for both GraphQL and REST APIs
+RSpec.shared_examples 'group milestones including ancestors and descendants' do
+  context 'for group milestones' do
+    let_it_be(:current_user) { create(:user) }
+
+    context 'when including descendant milestones in a public group' do
+      let_it_be(:group) { create(:group, :public) }
+
+      let(:params) { { include_descendants: true } }
+
+      it 'finds milestones only in accessible projects and groups' do
+        accessible_group = create(:group, :private, parent: group)
+        accessible_project = create(:project, group: accessible_group)
+        accessible_group.add_developer(current_user)
+        inaccessible_group = create(:group, :private, parent: group)
+        inaccessible_project = create(:project, :private, group: group)
+        milestone1 = create(:milestone, group: group)
+        milestone2 = create(:milestone, group: accessible_group)
+        milestone3 = create(:milestone, project: accessible_project)
+        create(:milestone, group: inaccessible_group)
+        create(:milestone, project: inaccessible_project)
+
+        milestone_ids = query_group_milestone_ids(params)
+
+        expect(milestone_ids).to match_array([milestone1, milestone2, milestone3].pluck(:id))
+      end
+    end
+
+    describe 'include_descendants and include_ancestors' do
+      let_it_be(:parent_group) { create(:group, :public) }
+      let_it_be(:group) { create(:group, :public, parent: parent_group) }
+      let_it_be(:accessible_group) { create(:group, :private, parent: group) }
+      let_it_be(:accessible_project) { create(:project, group: accessible_group) }
+      let_it_be(:inaccessible_group) { create(:group, :private, parent: group) }
+      let_it_be(:inaccessible_project) { create(:project, :private, group: group) }
+      let_it_be(:milestone1) { create(:milestone, group: group) }
+      let_it_be(:milestone2) { create(:milestone, group: accessible_group) }
+      let_it_be(:milestone3) { create(:milestone, project: accessible_project) }
+      let_it_be(:milestone4) { create(:milestone, group: inaccessible_group) }
+      let_it_be(:milestone5) { create(:milestone, project: inaccessible_project) }
+      let_it_be(:milestone6) { create(:milestone, group: parent_group) }
+
+      before_all do
+        accessible_group.add_developer(current_user)
+      end
+
+      context 'when including neither ancestor nor descendant milestones in a public group' do
+        let(:params) { {} }
+
+        it 'finds milestones only in accessible projects and groups' do
+          expect(query_group_milestone_ids(params)).to match_array([milestone1.id])
+        end
+      end
+
+      context 'when including descendant milestones in a public group' do
+        let(:params) { { include_descendants: true } }
+
+        it 'finds milestones only in accessible projects and groups' do
+          expect(query_group_milestone_ids(params)).to match_array([milestone1, milestone2, milestone3].pluck(:id))
+        end
+      end
+
+      context 'when including ancestor milestones in a public group' do
+        let(:params) { { include_ancestors: true } }
+
+        it 'finds milestones only in accessible projects and groups' do
+          expect(query_group_milestone_ids(params)).to match_array([milestone1, milestone6].pluck(:id))
+        end
+      end
+
+      context 'when including both ancestor and descendant milestones in a public group' do
+        let(:params) { { include_descendants: true, include_ancestors: true } }
+
+        it 'finds milestones only in accessible projects and groups' do
+          expect(query_group_milestone_ids(params))
+            .to match_array([milestone1, milestone2, milestone3, milestone6].pluck(:id))
+        end
+      end
+    end
+  end
+end
-- 
GitLab