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 @@
# Search for milestones
#
# params - Hash
# ids - filters by id.
# 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.
# order - Orders by field default due date asc.
......@@ -21,6 +22,7 @@ class MilestonesFinder
def execute
items = Milestone.all
items = by_ids(items)
items = by_groups_and_projects(items)
items = by_title(items)
items = by_search_title(items)
......@@ -32,6 +34,12 @@ class MilestonesFinder
private
def by_ids(items)
return items unless params[:ids].present?
items.id_in(params[:ids])
end
def by_groups_and_projects(items)
items.for_projects_and_groups(params[:project_ids], params[:group_ids])
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
include Gitlab::Graphql::Authorize::AuthorizeResource
include TimeFrameArguments
argument :state, Types::MilestoneStateEnum,
argument :ids, [GraphQL::ID_TYPE],
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,
description: 'Return also milestones in all subgroups and subprojects'
description: 'Filter milestones by state'
type Types::MilestoneType, null: true
......@@ -27,22 +27,19 @@ module Resolvers
def milestones_finder_params(args)
{
ids: parse_gids(args[:ids]),
state: args[:state] || 'all',
start_date: args[:start_date],
end_date: args[:end_date]
}.merge(parent_id_parameter(args))
}.merge(parent_id_parameters(args))
end
def parent
@parent ||= object.respond_to?(:sync) ? object.sync : object
end
def parent_id_parameter(args)
if parent.is_a?(Group)
group_parameters(args)
elsif parent.is_a?(Project)
{ project_ids: parent.id }
end
def parent_id_parameters(args)
raise NotImplementedError
end
# MilestonesFinder does not check for current_user permissions,
......@@ -51,21 +48,8 @@ module Resolvers
Ability.allowed?(context[:current_user], :read_milestone, parent) || raise_resource_not_available_error!
end
def group_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
def parse_gids(gids)
gids&.map { |gid| GitlabSchema.parse_gid(gid, expected_type: Milestone).model_id }
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
resolver: Resolvers::IssuesResolver
field :milestones, Types::MilestoneType.connection_type, null: true,
description: 'Find milestones',
resolver: Resolvers::MilestoneResolver
description: 'Milestones of the group',
resolver: Resolvers::GroupMilestoneResolver
field :boards,
Types::BoardType.connection_type,
......
......@@ -148,6 +148,10 @@ module Types
description: 'Issues of the project',
resolver: Resolvers::IssuesResolver
field :milestones, Types::MilestoneType.connection_type, null: true,
description: 'Milestones of the project',
resolver: Resolvers::ProjectMilestoneResolver
field :project_members,
Types::ProjectMemberType.connection_type,
description: 'Members of the project',
......
---
title: Add project milestones to GraphQL API
merge_request: 38153
author:
type: added
......@@ -5342,7 +5342,7 @@ type Group {
mentionsDisabled: Boolean
"""
Find milestones
Milestones of the group
"""
milestones(
"""
......@@ -5367,7 +5367,12 @@ type Group {
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
......@@ -9710,6 +9715,58 @@ type Project {
"""
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)
"""
......
......@@ -14755,7 +14755,7 @@
},
{
"name": "milestones",
"description": "Find milestones",
"description": "Milestones of the group",
"args": [
{
"name": "startDate",
......@@ -14777,6 +14777,24 @@
},
"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",
......@@ -14789,7 +14807,7 @@
},
{
"name": "includeDescendants",
"description": "Return also milestones in all subgroups and subprojects",
"description": "Also return milestones in all subgroups and subprojects",
"type": {
"kind": "SCALAR",
"name": "Boolean",
......@@ -28776,6 +28794,117 @@
"isDeprecated": false,
"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",
"description": "Name of the project (without namespace)",
......@@ -56,6 +56,14 @@ RSpec.describe MilestonesFinder do
milestone_3.close
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
params[:state] = 'active'
result = described_class.new(params).execute
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe Resolvers::MilestoneResolver do
RSpec.describe Resolvers::GroupMilestoneResolver do
include GraphqlHelpers
describe '#resolve' do
......@@ -12,84 +12,94 @@ RSpec.describe Resolvers::MilestoneResolver do
resolve(described_class, obj: group, args: args, ctx: context)
end
context 'for group milestones' do
let_it_be(:now) { Time.now }
let_it_be(:group) { create(:group, :private) }
let_it_be(:now) { Time.now }
let_it_be(:group) { create(:group, :private) }
before do
group.add_developer(current_user)
before_all do
group.add_developer(current_user)
end
it 'calls MilestonesFinder#execute' do
expect_next_instance_of(MilestonesFinder) do |finder|
expect(finder).to receive(:execute)
end
it 'calls MilestonesFinder#execute' do
expect_next_instance_of(MilestonesFinder) do |finder|
expect(finder).to receive(:execute)
end
resolve_group_milestones
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
end
end
context 'without parameters' do
it 'calls MilestonesFinder to retrieve all milestones' do
expect(MilestonesFinder).to receive(:new)
.with(group_ids: group.id, state: 'all', start_date: nil, end_date: nil)
.and_call_original
context 'with parameters' do
it 'calls MilestonesFinder with correct parameters' do
start_date = now
end_date = start_date + 1.hour
resolve_group_milestones
end
expect(MilestonesFinder).to receive(:new)
.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
context 'with parameters' do
it 'calls MilestonesFinder with correct parameters' do
start_date = now
end_date = start_date + 1.hour
context 'by ids' do
it 'calls MilestonesFinder with correct parameters' do
milestone = create(:milestone, group: group)
expect(MilestonesFinder).to receive(:new)
.with(group_ids: group.id, state: 'closed', start_date: start_date, end_date: end_date)
.and_call_original
expect(MilestonesFinder).to receive(:new)
.with(ids: [milestone.id.to_s], group_ids: group.id, state: 'all', start_date: nil, end_date: nil)
.and_call_original
resolve_group_milestones(start_date: start_date, end_date: end_date, state: 'closed')
end
resolve_group_milestones(ids: [milestone.to_global_id])
end
end
context 'by timeframe' do
context 'when start_date and end_date are present' 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
context 'by timeframe' do
context 'when start_date and end_date are present' do
context 'when start date is after end_date' do
it 'raises error' do
expect do
resolve_group_milestones(start_date: now)
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, /Both startDate and endDate/)
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 end_date is present' do
it 'raises error' do
expect do
resolve_group_milestones(end_date: now)
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, /Both startDate and endDate/)
end
context 'when only start_date is present' do
it 'raises error' do
expect do
resolve_group_milestones(start_date: now)
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, /Both startDate and endDate/)
end
end
context 'when user cannot read milestones' do
context 'when only end_date is present' 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)
resolve_group_milestones(end_date: 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_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
let_it_be(:group) { create(:group, :public) }
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
web_url avatar_url share_with_group_lock project_creation_level
subgroup_creation_level require_two_factor_authentication
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)
......
......@@ -22,7 +22,7 @@ RSpec.describe GitlabSchema.types['Project'] do
only_allow_merge_if_pipeline_succeeds request_access_enabled
only_allow_merge_if_all_discussions_are_resolved printing_merge_request_link_enabled
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
boards jira_import_status jira_imports services releases release
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