Commit 4ce3e71e authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Merge branch 'project_iterations' into 'master'

Support project-level iteration creation

See merge request gitlab-org/gitlab!38345
parents 210f7e9b 8f5ed601
......@@ -1964,7 +1964,12 @@ input CreateIterationInput {
"""
The target group for the iteration
"""
groupPath: ID!
groupPath: ID
"""
The target project for the iteration
"""
projectPath: ID
"""
The start date of the iteration
......@@ -9714,6 +9719,68 @@ type Project {
"""
issuesEnabled: Boolean
"""
Find iterations
"""
iterations(
"""
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
"""
The ID of the Iteration to look up
"""
id: ID
"""
The internal ID of the Iteration to look up
"""
iid: ID
"""
Whether to include ancestor iterations. Defaults to true
"""
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 iterations by state
"""
state: IterationState
"""
Fuzzy search by title
"""
title: String
): IterationConnection
"""
Status of Jira import background job of the project
"""
......
......@@ -5202,13 +5202,19 @@
"name": "groupPath",
"description": "The target group for the iteration",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
{
"name": "projectPath",
"description": "The target project for the iteration",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
......@@ -28960,6 +28966,129 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "iterations",
"description": "Find iterations",
"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": "state",
"description": "Filter iterations by state",
"type": {
"kind": "ENUM",
"name": "IterationState",
"ofType": null
},
"defaultValue": null
},
{
"name": "title",
"description": "Fuzzy search by title",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "id",
"description": "The ID of the Iteration to look up",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
{
"name": "iid",
"description": "The internal ID of the Iteration to look up",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
{
"name": "includeAncestors",
"description": "Whether to include ancestor iterations. Defaults to true",
"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": "IterationConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "jiraImportStatus",
"description": "Status of Jira import background job of the project",
......@@ -60,6 +60,10 @@ module EE
Rails.application.routes.url_helpers.project_security_dashboard_index_path(project)
end
field :iterations, ::Types::IterationType.connection_type, null: true,
description: 'Find iterations',
resolver: ::Resolvers::IterationsResolver
def self.requirements_available?(project, user)
::Feature.enabled?(:requirements_management, project, default_enabled: true) && Ability.allowed?(user, :read_requirement, project)
end
......
......@@ -4,6 +4,7 @@ module Mutations
module Iterations
class Create < BaseMutation
include Mutations::ResolvesGroup
include ResolvesProject
graphql_name 'CreateIteration'
......@@ -15,9 +16,13 @@ module Mutations
description: 'The created iteration'
argument :group_path, GraphQL::ID_TYPE,
required: true,
required: false,
description: "The target group for the iteration"
argument :project_path, GraphQL::ID_TYPE,
required: false,
description: "The target project for the iteration"
argument :title,
GraphQL::STRING_TYPE,
required: false,
......@@ -39,12 +44,11 @@ module Mutations
description: 'The end date of the iteration'
def resolve(args)
group_path = args.delete(:group_path)
validate_arguments!(args)
group = authorized_find!(group_path: group_path)
response = ::Iterations::CreateService.new(group, current_user, args).execute
parent = find_parent(args)
response = ::Iterations::CreateService.new(parent, current_user, args).execute
response_object = response.payload[:iteration] if response.success?
response_errors = response.error? ? response.payload[:errors].full_messages : []
......@@ -57,15 +61,40 @@ module Mutations
private
def find_object(group_path:)
resolve_group(full_path: group_path)
def find_object(group_path: nil, project_path: nil)
if group_path
resolve_group(full_path: group_path)
elsif project_path
resolve_project(full_path: project_path)
end
end
def find_parent(args)
group_path = args.delete(:group_path)
project_path = args.delete(:project_path)
if group_path
authorized_find!(group_path: group_path)
elsif project_path
authorized_find!(project_path: project_path)
end
end
def validate_arguments!(args)
if args.empty?
if args.except(:group_path, :project_path).empty?
raise Gitlab::Graphql::Errors::ArgumentError,
'The list of iteration attributes is empty'
end
if args[:group_path].present? && args[:project_path].present?
raise Gitlab::Graphql::Errors::ArgumentError,
'Only one of group_path or project_path can be provided'
end
if args[:group_path].nil? && args[:project_path].nil?
raise Gitlab::Graphql::Errors::ArgumentError,
'Either group_path or project_path is required'
end
end
end
end
......
---
title: Support project-level iteration creation via GraphQL
merge_request: 38345
author:
type: added
......@@ -14,7 +14,7 @@ module EE
when Epic
instance.group_epic_url(object.group, object, **options)
when Iteration
instance.group_iteration_url(object.group, object, **options)
instance.iteration_url(object, **options)
when Vulnerability
instance.project_security_vulnerability_url(object.project, object, **options)
else
......
......@@ -17,7 +17,7 @@ RSpec.describe GitlabSchema.types['Project'] do
expected_fields = %w[
vulnerabilities vulnerability_scanners requirement_states_count
vulnerability_severities_count packages compliance_frameworks
security_dashboard_path
security_dashboard_path iterations
]
expect(described_class).to include_graphql_fields(*expected_fields)
......
......@@ -19,10 +19,14 @@ RSpec.describe 'Creating an Iteration' do
}
end
let(:mutation) do
params = { group_path: group.full_path }.merge(attributes)
let(:params) do
{
group_path: group.full_path
}
end
graphql_mutation(:create_iteration, params)
let(:mutation) do
graphql_mutation(:create_iteration, params.merge(attributes))
end
def mutation_response
......@@ -63,7 +67,7 @@ RSpec.describe 'Creating an Iteration' do
stub_licensed_features(iterations: true)
end
it 'creates the iteration' do
it 'creates the iteration for a group' do
post_graphql_mutation(mutation, current_user: current_user)
iteration_hash = mutation_response['iteration']
......@@ -75,6 +79,27 @@ RSpec.describe 'Creating an Iteration' do
end
end
context 'when a project_path is given' do
let_it_be(:project) { create(:project, namespace: group) }
let(:params) { { project_path: project.full_path } }
before do
project.add_developer(current_user)
end
it 'creates the iteration for a project' do
post_graphql_mutation(mutation, current_user: current_user)
iteration_hash = mutation_response['iteration']
aggregate_failures do
expect(iteration_hash['title']).to eq('title')
expect(iteration_hash['description']).to eq('some description')
expect(iteration_hash['startDate']).to eq(start_date)
expect(iteration_hash['dueDate']).to eq(end_date)
end
end
end
context 'when there are ActiveRecord validation errors' do
let(:attributes) { { title: '' } }
......@@ -96,6 +121,28 @@ RSpec.describe 'Creating an Iteration' do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Iteration, :count)
end
end
context 'when the params contains neither group nor project path' do
let(:params) { {} }
it_behaves_like 'a mutation that returns top-level errors',
errors: ['Either group_path or project_path is required']
it 'does not create the iteration' do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Iteration, :count)
end
end
context 'when the params contains both group and project path' do
let(:params) { { group_path: group.full_path, project_path: 'doesnotreallymatter' } }
it_behaves_like 'a mutation that returns top-level errors',
errors: ['Only one of group_path or project_path can be provided']
it 'does not create the iteration' do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Iteration, :count)
end
end
end
end
end
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