Commit b26232ed authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Add project milestones to GraphQL API

Also adds filtering by IDs so we can get a specific milestone
parent 5755bdd5
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
# Search for milestones # Search for milestones
# #
# params - Hash # params - Hash
# ids - filters by id.
# project_ids: Array of project ids or single project id or ActiveRecord relation. # project_ids: Array of project ids or single project id or ActiveRecord relation.
# group_ids: Array of group ids or single group id or ActiveRecord relation. # group_ids: Array of group ids or single group id or ActiveRecord relation.
# order - Orders by field default due date asc. # order - Orders by field default due date asc.
...@@ -21,6 +22,7 @@ class MilestonesFinder ...@@ -21,6 +22,7 @@ class MilestonesFinder
def execute def execute
items = Milestone.all items = Milestone.all
items = by_ids(items)
items = by_groups_and_projects(items) items = by_groups_and_projects(items)
items = by_title(items) items = by_title(items)
items = by_search_title(items) items = by_search_title(items)
...@@ -32,6 +34,12 @@ class MilestonesFinder ...@@ -32,6 +34,12 @@ class MilestonesFinder
private private
def by_ids(items)
return items unless params[:ids].present?
items.id_in(params[:ids])
end
def by_groups_and_projects(items) def by_groups_and_projects(items)
items.for_projects_and_groups(params[:project_ids], params[:group_ids]) items.for_projects_and_groups(params[:project_ids], params[:group_ids])
end end
......
# frozen_string_literal: true
module Resolvers
class GroupMilestoneResolver < MilestoneResolver
argument :include_descendants, GraphQL::BOOLEAN_TYPE,
required: false,
description: 'Also return milestones in all subgroups and subprojects'
private
def parent_id_parameters(args)
return { group_ids: parent.id } unless args[:include_descendants].present?
{
group_ids: parent.self_and_descendants.public_or_visible_to_user(current_user).select(:id),
project_ids: group_projects.with_issues_or_mrs_available_for_user(current_user)
}
end
def group_projects
GroupProjectsFinder.new(
group: parent,
current_user: current_user,
options: { include_subgroups: true }
).execute
end
end
end
...@@ -5,13 +5,13 @@ module Resolvers ...@@ -5,13 +5,13 @@ module Resolvers
include Gitlab::Graphql::Authorize::AuthorizeResource include Gitlab::Graphql::Authorize::AuthorizeResource
include TimeFrameArguments include TimeFrameArguments
argument :state, Types::MilestoneStateEnum, argument :ids, [GraphQL::ID_TYPE],
required: false, required: false,
description: 'Filter milestones by state' description: 'Array of global milestone IDs, e.g., "gid://gitlab/Milestone/1"'
argument :include_descendants, GraphQL::BOOLEAN_TYPE, argument :state, Types::MilestoneStateEnum,
required: false, required: false,
description: 'Return also milestones in all subgroups and subprojects' description: 'Filter milestones by state'
type Types::MilestoneType, null: true type Types::MilestoneType, null: true
...@@ -27,22 +27,19 @@ module Resolvers ...@@ -27,22 +27,19 @@ module Resolvers
def milestones_finder_params(args) def milestones_finder_params(args)
{ {
ids: parse_gids(args[:ids]),
state: args[:state] || 'all', state: args[:state] || 'all',
start_date: args[:start_date], start_date: args[:start_date],
end_date: args[:end_date] end_date: args[:end_date]
}.merge(parent_id_parameter(args)) }.merge(parent_id_parameters(args))
end end
def parent def parent
@parent ||= object.respond_to?(:sync) ? object.sync : object @parent ||= object.respond_to?(:sync) ? object.sync : object
end end
def parent_id_parameter(args) def parent_id_parameters(args)
if parent.is_a?(Group) raise NotImplementedError
group_parameters(args)
elsif parent.is_a?(Project)
{ project_ids: parent.id }
end
end end
# MilestonesFinder does not check for current_user permissions, # MilestonesFinder does not check for current_user permissions,
...@@ -51,21 +48,8 @@ module Resolvers ...@@ -51,21 +48,8 @@ module Resolvers
Ability.allowed?(context[:current_user], :read_milestone, parent) || raise_resource_not_available_error! Ability.allowed?(context[:current_user], :read_milestone, parent) || raise_resource_not_available_error!
end end
def group_parameters(args) def parse_gids(gids)
return { group_ids: parent.id } unless args[:include_descendants].present? gids&.map { |gid| GitlabSchema.parse_gid(gid, expected_type: Milestone).model_id }
{
group_ids: parent.self_and_descendants.public_or_visible_to_user(current_user).select(:id),
project_ids: group_projects.with_issues_or_mrs_available_for_user(current_user)
}
end
def group_projects
GroupProjectsFinder.new(
group: parent,
current_user: current_user,
options: { include_subgroups: true }
).execute
end end
end end
end end
# frozen_string_literal: true
module Resolvers
class ProjectMilestoneResolver < MilestoneResolver
argument :include_ancestors, GraphQL::BOOLEAN_TYPE,
required: false,
description: "Also return milestones in the project's parent group and its ancestors"
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
}
end
end
end
...@@ -50,8 +50,8 @@ module Types ...@@ -50,8 +50,8 @@ module Types
resolver: Resolvers::IssuesResolver resolver: Resolvers::IssuesResolver
field :milestones, Types::MilestoneType.connection_type, null: true, field :milestones, Types::MilestoneType.connection_type, null: true,
description: 'Find milestones', description: 'Milestones of the group',
resolver: Resolvers::MilestoneResolver resolver: Resolvers::GroupMilestoneResolver
field :boards, field :boards,
Types::BoardType.connection_type, Types::BoardType.connection_type,
......
...@@ -148,6 +148,10 @@ module Types ...@@ -148,6 +148,10 @@ module Types
description: 'Issues of the project', description: 'Issues of the project',
resolver: Resolvers::IssuesResolver resolver: Resolvers::IssuesResolver
field :milestones, Types::MilestoneType.connection_type, null: true,
description: 'Milestones of the project',
resolver: Resolvers::ProjectMilestoneResolver
field :project_members, field :project_members,
Types::ProjectMemberType.connection_type, Types::ProjectMemberType.connection_type,
description: 'Members of the project', description: 'Members of the project',
......
---
title: Add project milestones to GraphQL API
merge_request: 38153
author:
type: added
...@@ -5342,7 +5342,7 @@ type Group { ...@@ -5342,7 +5342,7 @@ type Group {
mentionsDisabled: Boolean mentionsDisabled: Boolean
""" """
Find milestones Milestones of the group
""" """
milestones( milestones(
""" """
...@@ -5367,7 +5367,12 @@ type Group { ...@@ -5367,7 +5367,12 @@ type Group {
first: Int first: Int
""" """
Return also milestones in all subgroups and subprojects Array of global milestone IDs, e.g., "gid://gitlab/Milestone/1"
"""
ids: [ID!]
"""
Also return milestones in all subgroups and subprojects
""" """
includeDescendants: Boolean includeDescendants: Boolean
...@@ -9710,6 +9715,58 @@ type Project { ...@@ -9710,6 +9715,58 @@ type Project {
""" """
mergeRequestsFfOnlyEnabled: Boolean mergeRequestsFfOnlyEnabled: Boolean
"""
Milestones of the project
"""
milestones(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
List items within a time frame where items.end_date is between startDate and
endDate parameters (startDate parameter must be present)
"""
endDate: Time
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Array of global milestone IDs, e.g., "gid://gitlab/Milestone/1"
"""
ids: [ID!]
"""
Also return milestones in the project's parent group and its ancestors
"""
includeAncestors: Boolean
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
List items within a time frame where items.start_date is between startDate
and endDate parameters (endDate parameter must be present)
"""
startDate: Time
"""
Filter milestones by state
"""
state: MilestoneStateEnum
): MilestoneConnection
""" """
Name of the project (without namespace) Name of the project (without namespace)
""" """
......
...@@ -14755,7 +14755,7 @@ ...@@ -14755,7 +14755,7 @@
}, },
{ {
"name": "milestones", "name": "milestones",
"description": "Find milestones", "description": "Milestones of the group",
"args": [ "args": [
{ {
"name": "startDate", "name": "startDate",
...@@ -14777,6 +14777,24 @@ ...@@ -14777,6 +14777,24 @@
}, },
"defaultValue": null "defaultValue": null
}, },
{
"name": "ids",
"description": "Array of global milestone IDs, e.g., \"gid://gitlab/Milestone/1\"",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
}
},
"defaultValue": null
},
{ {
"name": "state", "name": "state",
"description": "Filter milestones by state", "description": "Filter milestones by state",
...@@ -14789,7 +14807,7 @@ ...@@ -14789,7 +14807,7 @@
}, },
{ {
"name": "includeDescendants", "name": "includeDescendants",
"description": "Return also milestones in all subgroups and subprojects", "description": "Also return milestones in all subgroups and subprojects",
"type": { "type": {
"kind": "SCALAR", "kind": "SCALAR",
"name": "Boolean", "name": "Boolean",
...@@ -28776,6 +28794,117 @@ ...@@ -28776,6 +28794,117 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "milestones",
"description": "Milestones of the project",
"args": [
{
"name": "startDate",
"description": "List items within a time frame where items.start_date is between startDate and endDate parameters (endDate parameter must be present)",
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"defaultValue": null
},
{
"name": "endDate",
"description": "List items within a time frame where items.end_date is between startDate and endDate parameters (startDate parameter must be present)",
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"defaultValue": null
},
{
"name": "ids",
"description": "Array of global milestone IDs, e.g., \"gid://gitlab/Milestone/1\"",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
}
},
"defaultValue": null
},
{
"name": "state",
"description": "Filter milestones by state",
"type": {
"kind": "ENUM",
"name": "MilestoneStateEnum",
"ofType": null
},
"defaultValue": null
},
{
"name": "includeAncestors",
"description": "Also return milestones in the project's parent group and its ancestors",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": null
},
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "MilestoneConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "name", "name": "name",
"description": "Name of the project (without namespace)", "description": "Name of the project (without namespace)",
...@@ -56,6 +56,14 @@ RSpec.describe MilestonesFinder do ...@@ -56,6 +56,14 @@ RSpec.describe MilestonesFinder do
milestone_3.close milestone_3.close
end end
it 'filters by id' do
params[:ids] = [milestone_1.id, milestone_2.id]
result = described_class.new(params).execute
expect(result).to contain_exactly(milestone_1, milestone_2)
end
it 'filters by active state' do it 'filters by active state' do
params[:state] = 'active' params[:state] = 'active'
result = described_class.new(params).execute result = described_class.new(params).execute
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Resolvers::MilestoneResolver do RSpec.describe Resolvers::GroupMilestoneResolver do
include GraphqlHelpers include GraphqlHelpers
describe '#resolve' do describe '#resolve' do
...@@ -12,84 +12,94 @@ RSpec.describe Resolvers::MilestoneResolver do ...@@ -12,84 +12,94 @@ RSpec.describe Resolvers::MilestoneResolver do
resolve(described_class, obj: group, args: args, ctx: context) resolve(described_class, obj: group, args: args, ctx: context)
end end
context 'for group milestones' do let_it_be(:now) { Time.now }
let_it_be(:now) { Time.now } let_it_be(:group) { create(:group, :private) }
let_it_be(:group) { create(:group, :private) }
before do before_all do
group.add_developer(current_user) group.add_developer(current_user)
end
it 'calls MilestonesFinder#execute' do
expect_next_instance_of(MilestonesFinder) do |finder|
expect(finder).to receive(:execute)
end end
it 'calls MilestonesFinder#execute' do resolve_group_milestones
expect_next_instance_of(MilestonesFinder) do |finder| end
expect(finder).to receive(:execute)
end context 'without parameters' do
it 'calls MilestonesFinder to retrieve all milestones' do
expect(MilestonesFinder).to receive(:new)
.with(ids: nil, group_ids: group.id, state: 'all', start_date: nil, end_date: nil)
.and_call_original
resolve_group_milestones resolve_group_milestones
end end
end
context 'without parameters' do context 'with parameters' do
it 'calls MilestonesFinder to retrieve all milestones' do it 'calls MilestonesFinder with correct parameters' do
expect(MilestonesFinder).to receive(:new) start_date = now
.with(group_ids: group.id, state: 'all', start_date: nil, end_date: nil) end_date = start_date + 1.hour
.and_call_original
resolve_group_milestones expect(MilestonesFinder).to receive(:new)
end .with(ids: nil, group_ids: group.id, state: 'closed', start_date: start_date, end_date: end_date)
.and_call_original
resolve_group_milestones(start_date: start_date, end_date: end_date, state: 'closed')
end end
end
context 'with parameters' do context 'by ids' do
it 'calls MilestonesFinder with correct parameters' do it 'calls MilestonesFinder with correct parameters' do
start_date = now milestone = create(:milestone, group: group)
end_date = start_date + 1.hour
expect(MilestonesFinder).to receive(:new) expect(MilestonesFinder).to receive(:new)
.with(group_ids: group.id, state: 'closed', start_date: start_date, end_date: end_date) .with(ids: [milestone.id.to_s], group_ids: group.id, state: 'all', start_date: nil, end_date: nil)
.and_call_original .and_call_original
resolve_group_milestones(start_date: start_date, end_date: end_date, state: 'closed') resolve_group_milestones(ids: [milestone.to_global_id])
end
end end
end
context 'by timeframe' do context 'by timeframe' do
context 'when start_date and end_date are present' do context 'when start_date and end_date are present' do
context 'when start date is after end_date' do context 'when start date is after end_date' do
it 'raises error' do
expect do
resolve_group_milestones(start_date: now, end_date: now - 2.days)
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, "startDate is after endDate")
end
end
end
context 'when only start_date is present' do
it 'raises error' do it 'raises error' do
expect do expect do
resolve_group_milestones(start_date: now) resolve_group_milestones(start_date: now, end_date: now - 2.days)
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, /Both startDate and endDate/) end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, "startDate is after endDate")
end end
end end
end
context 'when only end_date is present' do context 'when only start_date is present' do
it 'raises error' do it 'raises error' do
expect do expect do
resolve_group_milestones(end_date: now) resolve_group_milestones(start_date: now)
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, /Both startDate and endDate/) end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, /Both startDate and endDate/)
end
end end
end end
context 'when user cannot read milestones' do context 'when only end_date is present' do
it 'raises error' do it 'raises error' do
unauthorized_user = create(:user)
expect do expect do
resolve_group_milestones({}, { current_user: unauthorized_user }) resolve_group_milestones(end_date: now)
end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, /Both startDate and endDate/)
end end
end end
end end
context 'when user cannot read milestones' do
it 'raises error' do
unauthorized_user = create(:user)
expect do
resolve_group_milestones({}, { current_user: unauthorized_user })
end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when including descendant milestones in a public group' do context 'when including descendant milestones in a public group' do
let_it_be(:group) { create(:group, :public) } let_it_be(:group) { create(:group, :public) }
let(:args) { { include_descendants: true } } let(:args) { { include_descendants: true } }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::ProjectMilestoneResolver do
include GraphqlHelpers
describe '#resolve' do
let_it_be(:project) { create(:project, :private) }
let_it_be(:current_user) { create(:user) }
before_all do
project.add_developer(current_user)
end
def resolve_project_milestones(args = {}, context = { current_user: current_user })
resolve(described_class, obj: project, args: args, ctx: context)
end
it 'calls MilestonesFinder to retrieve all milestones' do
expect(MilestonesFinder).to receive(:new)
.with(ids: nil, project_ids: project.id, state: 'all', start_date: nil, end_date: nil)
.and_call_original
resolve_project_milestones
end
context 'when including ancestor milestones' do
let(:parent_group) { create(:group) }
let(:group) { create(:group, parent: parent_group) }
let(:project) { create(:project, group: group) }
before do
project.add_developer(current_user)
end
it 'calls MilestonesFinder with correct parameters' do
expect(MilestonesFinder).to receive(:new)
.with(ids: nil, project_ids: project.id, group_ids: contain_exactly(group, parent_group), state: 'all', start_date: nil, end_date: nil)
.and_call_original
resolve_project_milestones(include_ancestors: true)
end
end
context 'by ids' do
it 'calls MilestonesFinder with correct parameters' do
milestone = create(:milestone, project: project)
expect(MilestonesFinder).to receive(:new)
.with(ids: [milestone.id.to_s], project_ids: project.id, state: 'all', start_date: nil, end_date: nil)
.and_call_original
resolve_project_milestones(ids: [milestone.to_global_id])
end
end
context 'by state' do
it 'calls MilestonesFinder with correct parameters' do
expect(MilestonesFinder).to receive(:new)
.with(ids: nil, project_ids: project.id, state: 'closed', start_date: nil, end_date: nil)
.and_call_original
resolve_project_milestones(state: 'closed')
end
end
context 'by timeframe' do
context 'when start_date and end_date are present' do
it 'calls MilestonesFinder with correct parameters' do
start_date = Time.now
end_date = Time.now + 5.days
expect(MilestonesFinder).to receive(:new)
.with(ids: nil, project_ids: project.id, state: 'all', start_date: start_date, end_date: end_date)
.and_call_original
resolve_project_milestones(start_date: start_date, end_date: end_date)
end
context 'when start date is after end_date' do
it 'raises error' do
expect do
resolve_project_milestones(start_date: Time.now, end_date: Time.now - 2.days)
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, "startDate is after endDate")
end
end
end
context 'when only start_date is present' do
it 'raises error' do
expect do
resolve_project_milestones(start_date: Time.now)
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, /Both startDate and endDate/)
end
end
context 'when only end_date is present' do
it 'raises error' do
expect do
resolve_project_milestones(end_date: Time.now)
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, /Both startDate and endDate/)
end
end
end
context 'when user cannot read milestones' do
it 'raises error' do
unauthorized_user = create(:user)
expect do
resolve_project_milestones({}, { current_user: unauthorized_user })
end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
end
end
...@@ -16,7 +16,7 @@ RSpec.describe GitlabSchema.types['Group'] do ...@@ -16,7 +16,7 @@ RSpec.describe GitlabSchema.types['Group'] do
web_url avatar_url share_with_group_lock project_creation_level web_url avatar_url share_with_group_lock project_creation_level
subgroup_creation_level require_two_factor_authentication subgroup_creation_level require_two_factor_authentication
two_factor_grace_period auto_devops_enabled emails_disabled two_factor_grace_period auto_devops_enabled emails_disabled
mentions_disabled parent boards mentions_disabled parent boards milestones
] ]
expect(described_class).to include_graphql_fields(*expected_fields) expect(described_class).to include_graphql_fields(*expected_fields)
......
...@@ -22,7 +22,7 @@ RSpec.describe GitlabSchema.types['Project'] do ...@@ -22,7 +22,7 @@ RSpec.describe GitlabSchema.types['Project'] do
only_allow_merge_if_pipeline_succeeds request_access_enabled only_allow_merge_if_pipeline_succeeds request_access_enabled
only_allow_merge_if_all_discussions_are_resolved printing_merge_request_link_enabled only_allow_merge_if_all_discussions_are_resolved printing_merge_request_link_enabled
namespace group statistics repository merge_requests merge_request issues namespace group statistics repository merge_requests merge_request issues
issue pipelines removeSourceBranchAfterMerge sentryDetailedError snippets issue milestones pipelines removeSourceBranchAfterMerge sentryDetailedError snippets
grafanaIntegration autocloseReferencedIssues suggestion_commit_message environments grafanaIntegration autocloseReferencedIssues suggestion_commit_message environments
boards jira_import_status jira_imports services releases release boards jira_import_status jira_imports services releases release
alert_management_alerts alert_management_alert alert_management_alert_status_counts alert_management_alerts alert_management_alert alert_management_alert_status_counts
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment