Commit 3c4cedf8 authored by Alexandru Croitor's avatar Alexandru Croitor

Add create iteration cadence service and GraphQL mutation

Implement create iteration cadence service and GraphQL mutation

re https://gitlab.com/gitlab-org/gitlab/-/issues/293864
parent c2afaab7
...@@ -2602,6 +2602,30 @@ Represents an iteration object. ...@@ -2602,6 +2602,30 @@ Represents an iteration object.
| `webPath` | String! | Web path of the iteration. | | `webPath` | String! | Web path of the iteration. |
| `webUrl` | String! | Web URL of the iteration. | | `webUrl` | String! | Web URL of the iteration. |
### `IterationCadence`
Represents an iteration cadence.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `active` | Boolean | Whether the iteration cadence is active. |
| `automatic` | Boolean | Whether the iteration cadence should automatically generate future iterations. |
| `durationInWeeks` | Int! | Duration in weeks of the iterations within this cadence. |
| `id` | IterationsCadenceID! | Global ID of the iteration cadence. |
| `iterationsInAdvance` | Int! | Future iterations to be created when iteration cadence is set to automatic. |
| `startDate` | Time | Timestamp of the iteration cadence start date. |
| `title` | String! | Title of the iteration cadence. |
### `IterationCadenceCreatePayload`
Autogenerated return type of IterationCadenceCreate.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `iterationCadence` | IterationCadence | The created iteration cadence. |
### `JiraImport` ### `JiraImport`
| Field | Type | Description | | Field | Type | Description |
......
...@@ -25,6 +25,7 @@ module EE ...@@ -25,6 +25,7 @@ module EE
mount_mutation ::Mutations::GitlabSubscriptions::Activate mount_mutation ::Mutations::GitlabSubscriptions::Activate
mount_mutation ::Mutations::Iterations::Create mount_mutation ::Mutations::Iterations::Create
mount_mutation ::Mutations::Iterations::Update mount_mutation ::Mutations::Iterations::Update
mount_mutation ::Mutations::Iterations::Cadences::Create
mount_mutation ::Mutations::RequirementsManagement::CreateRequirement mount_mutation ::Mutations::RequirementsManagement::CreateRequirement
mount_mutation ::Mutations::RequirementsManagement::ExportRequirements mount_mutation ::Mutations::RequirementsManagement::ExportRequirements
mount_mutation ::Mutations::RequirementsManagement::UpdateRequirement mount_mutation ::Mutations::RequirementsManagement::UpdateRequirement
......
# frozen_string_literal: true
module Mutations
module Iterations
module Cadences
class Create < BaseMutation
include Mutations::ResolvesGroup
graphql_name 'IterationCadenceCreate'
authorize :create_iteration_cadence
argument :group_path, GraphQL::ID_TYPE, required: true,
description: "The group where the iteration cadence is created."
argument :title, GraphQL::STRING_TYPE, required: false,
description: copy_field_description(Types::Iterations::CadenceType, :title)
argument :duration_in_weeks, GraphQL::INT_TYPE, required: false,
description: copy_field_description(Types::Iterations::CadenceType, :duration_in_weeks)
argument :iterations_in_advance, GraphQL::INT_TYPE, required: false,
description: copy_field_description(Types::Iterations::CadenceType, :iterations_in_advance)
argument :start_date, Types::TimeType, required: false,
description: copy_field_description(Types::Iterations::CadenceType, :start_date)
argument :automatic, GraphQL::BOOLEAN_TYPE, required: true,
description: copy_field_description(Types::Iterations::CadenceType, :automatic)
argument :active, GraphQL::BOOLEAN_TYPE, required: true,
description: copy_field_description(Types::Iterations::CadenceType, :active)
field :iteration_cadence, Types::Iterations::CadenceType, null: true,
description: 'The created iteration cadence.'
def resolve(args)
group = authorized_find!(group_path: args.delete(:group_path))
response = ::Iterations::Cadences::CreateService.new(group, current_user, args).execute
response_object = response.payload[:iteration_cadence] if response.success?
response_errors = response.error? ? Array(response.errors) : []
{
iteration_cadence: response_object,
errors: response_errors
}
end
private
def find_object(group_path:)
resolve_group(full_path: group_path)
end
end
end
end
end
# frozen_string_literal: true
module Types
module Iterations
class CadenceType < BaseObject
graphql_name 'IterationCadence'
description 'Represents an iteration cadence'
authorize :read_iteration_cadence
field :id, ::Types::GlobalIDType[::Iterations::Cadence], null: false,
description: 'Global ID of the iteration cadence.'
field :title, GraphQL::STRING_TYPE, null: false,
description: 'Title of the iteration cadence.'
field :duration_in_weeks, GraphQL::INT_TYPE, null: false,
description: 'Duration in weeks of the iterations within this cadence.'
field :iterations_in_advance, GraphQL::INT_TYPE, null: false,
description: 'Future iterations to be created when iteration cadence is set to automatic.'
field :start_date, Types::TimeType, null: true,
description: 'Timestamp of the iteration cadence start date.'
field :automatic, GraphQL::BOOLEAN_TYPE, null: true,
description: 'Whether the iteration cadence should automatically generate future iterations.'
field :active, GraphQL::BOOLEAN_TYPE, null: true,
description: 'Whether the iteration cadence is active.'
end
end
end
...@@ -236,6 +236,10 @@ module EE ...@@ -236,6 +236,10 @@ module EE
feature_available?(:multiple_group_issue_boards) feature_available?(:multiple_group_issue_boards)
end end
def multiple_iteration_cadences_available?
feature_available?(:multiple_iteration_cadences)
end
def group_project_template_available? def group_project_template_available?
feature_available?(:group_project_templates) feature_available?(:group_project_templates)
end end
...@@ -466,6 +470,10 @@ module EE ...@@ -466,6 +470,10 @@ module EE
group_wiki_repository&.shard_name || ::Repository.pick_storage_shard group_wiki_repository&.shard_name || ::Repository.pick_storage_shard
end end
def iteration_cadences_feature_flag_enabled?
::Feature.enabled?(:iteration_cadences, self, default_enabled: :yaml)
end
private private
override :post_create_hook override :post_create_hook
......
...@@ -203,11 +203,18 @@ module EE ...@@ -203,11 +203,18 @@ module EE
self.iterations_cadence = default_cadence self.iterations_cadence = default_cadence
end end
# TODO: this method should be removed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/296099
def find_or_create_default_cadence def find_or_create_default_cadence
cadence_title = "#{group.name} Iterations" cadence_title = "#{group.name} Iterations"
start_date = self.start_date || Date.today start_date = self.start_date || Date.today
::Iterations::Cadence.create_with(title: cadence_title, start_date: start_date).safe_find_or_create_by!(group: group) ::Iterations::Cadence.create_with(
title: cadence_title,
start_date: start_date,
# set to 0, i.e. unspecified when creating default iterations as we do validate for presence.
iterations_in_advance: 0,
duration_in_weeks: 0
).safe_find_or_create_by!(group: group)
end end
# TODO: remove this as part of https://gitlab.com/gitlab-org/gitlab/-/issues/296100 # TODO: remove this as part of https://gitlab.com/gitlab-org/gitlab/-/issues/296100
......
...@@ -10,7 +10,9 @@ module Iterations ...@@ -10,7 +10,9 @@ module Iterations
validates :title, presence: true validates :title, presence: true
validates :start_date, presence: true validates :start_date, presence: true
validates :group_id, presence: true validates :group_id, presence: true
validates :active, presence: true validates :duration_in_weeks, presence: true
validates :automatic, presence: true validates :iterations_in_advance, presence: true
validates :active, inclusion: [true, false]
validates :automatic, inclusion: [true, false]
end end
end end
...@@ -111,6 +111,7 @@ class License < ApplicationRecord ...@@ -111,6 +111,7 @@ class License < ApplicationRecord
multiple_alert_http_integrations multiple_alert_http_integrations
multiple_approval_rules multiple_approval_rules
multiple_group_issue_boards multiple_group_issue_boards
multiple_iteration_cadences
object_storage object_storage
operations_dashboard operations_dashboard
package_forwarding package_forwarding
......
...@@ -210,11 +210,15 @@ module EE ...@@ -210,11 +210,15 @@ module EE
enable :read_epic_board_list enable :read_epic_board_list
end end
rule { can?(:read_group) & iterations_available }.enable :read_iteration rule { can?(:read_group) & iterations_available }.policy do
enable :read_iteration
enable :read_iteration_cadence
end
rule { developer & iterations_available }.policy do rule { developer & iterations_available }.policy do
enable :create_iteration enable :create_iteration
enable :admin_iteration enable :admin_iteration
enable :create_iteration_cadence
end end
rule { reporter & epics_available }.policy do rule { reporter & epics_available }.policy do
......
# frozen_string_literal: true
module Iterations
class CadencePolicy < BasePolicy
delegate { @subject.group }
end
end
# frozen_string_literal: true
module Iterations
module Cadences
class CreateService
include Gitlab::Allowable
attr_accessor :group, :current_user, :params
def initialize(group, user, params = {})
@group, @current_user, @params = group, user, params.dup
end
def execute
return ::ServiceResponse.error(message: _('Operation not allowed'), http_status: 403) unless can_create_iteration_cadence?
iteration_cadence = group.iterations_cadences.new(params)
if iteration_cadence.save
::ServiceResponse.success(payload: { iteration_cadence: iteration_cadence })
else
::ServiceResponse.error(message: iteration_cadence.errors.full_messages, http_status: 422)
end
end
private
def can_create_iteration_cadence?
group.iteration_cadences_feature_flag_enabled? &&
can?(current_user, :create_iteration_cadence, group) &&
can_create_single_or_multiple_iteration_cadences?
end
def can_create_single_or_multiple_iteration_cadences?
group.feature_available?(:iterations) && (group.iterations_cadences.empty? || group.multiple_iteration_cadences_available?)
end
end
end
end
---
name: iteration_cadences
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/54822
rollout_issue_url:
milestone: '13.10'
type: development
group: group::project management
default_enabled: false
...@@ -7,6 +7,8 @@ FactoryBot.define do ...@@ -7,6 +7,8 @@ FactoryBot.define do
factory :iterations_cadence, class: 'Iterations::Cadence' do factory :iterations_cadence, class: 'Iterations::Cadence' do
title title
duration_in_weeks { 0 }
iterations_in_advance { 0 }
group group
start_date { generate(:cadence_sequential_date) } start_date { generate(:cadence_sequential_date) }
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['IterationCadence'] do
let(:fields) do
%i[id title duration_in_weeks iterations_in_advance start_date automatic active]
end
specify { expect(described_class.graphql_name).to eq('IterationCadence') }
specify { expect(described_class).to have_graphql_fields(fields) }
specify { expect(described_class).to require_graphql_authorizations(:read_iteration_cadence) }
end
...@@ -16,7 +16,7 @@ RSpec.describe ::Iterations::Cadence do ...@@ -16,7 +16,7 @@ RSpec.describe ::Iterations::Cadence do
it { is_expected.to validate_presence_of(:title) } it { is_expected.to validate_presence_of(:title) }
it { is_expected.to validate_presence_of(:start_date) } it { is_expected.to validate_presence_of(:start_date) }
it { is_expected.to validate_presence_of(:group_id) } it { is_expected.to validate_presence_of(:group_id) }
it { is_expected.to validate_presence_of(:active) } it { is_expected.not_to allow_value(nil).for(:active) }
it { is_expected.to validate_presence_of(:automatic) } it { is_expected.not_to allow_value(nil).for(:automatic) }
end end
end end
...@@ -82,7 +82,7 @@ RSpec.describe GroupPolicy do ...@@ -82,7 +82,7 @@ RSpec.describe GroupPolicy do
stub_licensed_features(iterations: false) stub_licensed_features(iterations: false)
end end
it { is_expected.to be_disallowed(:read_iteration, :create_iteration, :admin_iteration) } it { is_expected.to be_disallowed(:read_iteration, :create_iteration, :admin_iteration, :create_iteration_cadence) }
end end
context 'when iterations feature is enabled' do context 'when iterations feature is enabled' do
...@@ -93,20 +93,20 @@ RSpec.describe GroupPolicy do ...@@ -93,20 +93,20 @@ RSpec.describe GroupPolicy do
context 'when user is a developer' do context 'when user is a developer' do
let(:current_user) { developer } let(:current_user) { developer }
it { is_expected.to be_allowed(:read_iteration, :create_iteration, :admin_iteration) } it { is_expected.to be_allowed(:read_iteration, :create_iteration, :admin_iteration, :read_iteration_cadence, :create_iteration_cadence) }
end end
context 'when user is a guest' do context 'when user is a guest' do
let(:current_user) { guest } let(:current_user) { guest }
it { is_expected.to be_allowed(:read_iteration) } it { is_expected.to be_allowed(:read_iteration, :read_iteration_cadence) }
it { is_expected.to be_disallowed(:create_iteration, :admin_iteration) } it { is_expected.to be_disallowed(:create_iteration, :admin_iteration, :create_iteration_cadence) }
end end
context 'when user is logged out' do context 'when user is logged out' do
let(:current_user) { nil } let(:current_user) { nil }
it { is_expected.to be_disallowed(:read_iteration, :create_iteration, :admin_iteration) } it { is_expected.to be_disallowed(:read_iteration, :create_iteration, :admin_iteration, :create_iteration_cadence) }
end end
context 'when project is private' do context 'when project is private' do
...@@ -115,8 +115,8 @@ RSpec.describe GroupPolicy do ...@@ -115,8 +115,8 @@ RSpec.describe GroupPolicy do
context 'when user is logged out' do context 'when user is logged out' do
let(:current_user) { nil } let(:current_user) { nil }
it { is_expected.to be_allowed(:read_iteration) } it { is_expected.to be_allowed(:read_iteration, :read_iteration_cadence) }
it { is_expected.to be_disallowed(:create_iteration, :admin_iteration) } it { is_expected.to be_disallowed(:create_iteration, :admin_iteration, :create_iteration_cadence) }
end end
end end
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Creating an iteration cadence' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:group) { create(:group) }
let(:start_date) { Time.now.strftime('%F') }
let(:attributes) do
{
title: 'title',
start_date: start_date,
duration_in_weeks: 1,
iterations_in_advance: 1,
automatic: false,
active: false
}
end
let(:params) do
{
group_path: group.full_path
}
end
let(:mutation) do
graphql_mutation(:iteration_cadence_create, params.merge(attributes))
end
def mutation_response
graphql_mutation_response(:iteration_cadence_create)
end
context 'when the user does not have permission' do
before do
stub_licensed_features(iterations: true)
end
it_behaves_like 'a mutation that returns a top-level access error'
it 'does not create iteration cadence' do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Iterations::Cadence, :count)
end
end
context 'when the user has permission' do
before do
group.add_developer(current_user)
end
context 'when iterations feature is disabled' do
before do
stub_licensed_features(iterations: false)
end
it_behaves_like 'a mutation that returns top-level errors',
errors: ['The resource that you are attempting to access does not '\
'exist or you don\'t have permission to perform this action']
end
context 'when iterations feature is enabled' do
before do
stub_licensed_features(iterations: true)
end
it 'creates the iteration cadence for a group' do
post_graphql_mutation(mutation, current_user: current_user)
iteration_cadence_hash = mutation_response['iterationCadence']
aggregate_failures do
expect(iteration_cadence_hash['title']).to eq('title')
expect(iteration_cadence_hash['startDate']).to eq(start_date)
end
end
context 'when iteration_cadences feature flag is disabled' do
before do
stub_feature_flags(iteration_cadences: false)
end
it_behaves_like 'a mutation that returns errors in the response', errors: ["Operation not allowed"]
end
context 'when there are ActiveRecord validation errors' do
let(:attributes) { { title: '', duration_in_weeks: 1, active: false, automatic: false } }
it_behaves_like 'a mutation that returns errors in the response',
errors: ["Iterations in advance can't be blank", "Start date can't be blank", "Title can't be blank"]
it 'does not create the iteration cadence' do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Iterations::Cadence, :count)
end
end
context 'when required arguments are missing' do
let(:attributes) { { title: '', duration_in_weeks: 1, active: false } }
it 'returns error about required argument' do
post_graphql_mutation(mutation, current_user: current_user)
expect_graphql_errors_to_include(/was provided invalid value for automatic \(Expected value to not be null\)/)
end
it 'does not create the iteration cadence' do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Iterations::Cadence, :count)
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Iterations::Cadences::CreateService do
let_it_be(:group, refind: true) { create(:group) }
let_it_be(:user) { create(:user) }
before do
group.add_developer(user)
end
context 'iterations feature enabled' do
before do
stub_licensed_features(iterations: true)
end
describe '#execute' do
let(:params) do
{
title: 'My iteration cadence',
start_date: Time.current.to_s,
duration_in_weeks: 1,
iterations_in_advance: 1
}
end
let(:response) { described_class.new(group, user, params).execute }
let(:iteration_cadence) { response.payload[:iteration_cadence] }
let(:errors) { response.errors }
context 'valid params' do
it 'creates an iteration cadence' do
expect(response.success?).to be_truthy
expect(iteration_cadence).to be_persisted
expect(iteration_cadence.title).to eq('My iteration cadence')
expect(iteration_cadence.duration_in_weeks).to eq(1)
expect(iteration_cadence.iterations_in_advance).to eq(1)
expect(iteration_cadence.active).to eq(true)
expect(iteration_cadence.automatic).to eq(true)
end
end
context 'invalid params' do
let(:params) do
{
title: 'My iteration cadence'
}
end
it 'does not create an iteration cadence but returns errors' do
expect(response.error?).to be_truthy
expect(errors).to match([
"Start date can't be blank",
"Duration in weeks can't be blank",
"Iterations in advance can't be blank"
])
end
end
context 'no permissions' do
before do
group.add_reporter(user)
end
it 'is not allowed' do
expect(response.error?).to be_truthy
expect(response.message).to eq('Operation not allowed')
end
end
context 'when a single iteration cadence is allowed' do
let_it_be(:existing_iteration_cadence) { create(:iterations_cadence, group: group) }
before do
stub_licensed_features(multiple_iteration_cadences: false)
end
it 'fails to create multiple iteration cadences in same group' do
expect { response }.not_to change { Iterations::Cadence.count }
end
end
context 'when multiple iteration cadences are allowed' do
let_it_be(:existing_iteration_cadence) { create(:iterations_cadence, group: group) }
before do
stub_licensed_features(multiple_iteration_cadences: true)
end
it 'creates new iteration cadence' do
expect { response }.to change { Iterations::Cadence.count }.by(1)
end
end
end
end
context 'iterations feature disabled' do
before do
stub_licensed_features(iterations: false)
end
describe '#execute' do
let(:params) { { title: 'a' } }
let(:response) { described_class.new(group, user, params).execute }
it 'is not allowed' do
expect(response.error?).to be_truthy
expect(response.message).to eq('Operation not allowed')
end
end
end
context 'iteration cadences feature flag disabled' do
before do
stub_licensed_features(iterations: true)
stub_feature_flags(iteration_cadences: false)
end
describe '#execute' do
let(:params) { { title: 'a' } }
let(:response) { described_class.new(group, user, params).execute }
it 'is not allowed' do
expect(response.error?).to be_truthy
expect(response.message).to eq('Operation not allowed')
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