Commit fe8d5ea6 authored by Mario de la Ossa's avatar Mario de la Ossa Committed by Stan Hu

Add initial Sprints GraphQL endpoint

Adds a GraphQL type for Sprints as well as adding it to
Issues/Projects/Groups and adding an Issue::SetSprint and
Group::CreateSprint mutators.
parent 51494778
......@@ -53,7 +53,6 @@ module Timebox
scope :of_projects, ->(ids) { where(project_id: ids) }
scope :of_groups, ->(ids) { where(group_id: ids) }
scope :active, -> { with_state(:active) }
scope :closed, -> { with_state(:closed) }
scope :for_projects, -> { where(group: nil).includes(:project) }
scope :with_title, -> (title) { where(title: title) }
......
......@@ -5,9 +5,12 @@ class Iteration < ApplicationRecord
self.table_name = 'sprints'
STATE_ID_MAP = {
active: 1,
closed: 2
attr_accessor :skip_future_date_validation
STATE_ENUM_MAP = {
upcoming: 1,
started: 2,
closed: 3
}.with_indifferent_access.freeze
include AtomicInternalId
......@@ -21,16 +24,77 @@ class Iteration < ApplicationRecord
has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.iterations&.maximum(:iid) }
has_internal_id :iid, scope: :group, init: ->(s) { s&.group&.iterations&.maximum(:iid) }
state_machine :state, initial: :active do
validates :start_date, presence: true
validates :due_date, presence: true
validate :dates_do_not_overlap, if: :start_or_due_dates_changed?
validate :future_date, if: :start_or_due_dates_changed?, unless: :skip_future_date_validation
scope :upcoming, -> { with_state(:upcoming) }
scope :started, -> { with_state(:started) }
state_machine :state_enum, initial: :upcoming do
event :start do
transition upcoming: :started
end
event :close do
transition active: :closed
transition [:upcoming, :started] => :closed
end
event :activate do
transition closed: :active
state :upcoming, value: Iteration::STATE_ENUM_MAP[:upcoming]
state :started, value: Iteration::STATE_ENUM_MAP[:started]
state :closed, value: Iteration::STATE_ENUM_MAP[:closed]
end
# Alias to state machine .with_state_enum method
# This needs to be defined after the state machine block to avoid errors
class << self
alias_method :with_state, :with_state_enum
alias_method :with_states, :with_state_enums
def filter_by_state(iterations, state)
case state
when 'closed' then iterations.closed
when 'started' then iterations.started
when 'opened' then iterations.started.or(iterations.upcoming)
when 'all' then iterations
else iterations.upcoming
end
end
end
def state
STATE_ENUM_MAP.key(state_enum)
end
state :active, value: Iteration::STATE_ID_MAP[:active]
state :closed, value: Iteration::STATE_ID_MAP[:closed]
def state=(value)
self.state_enum = STATE_ENUM_MAP[value]
end
private
def start_or_due_dates_changed?
start_date_changed? || due_date_changed?
end
# ensure dates do not overlap with other Iterations in the same group/project
def dates_do_not_overlap
return unless resource_parent.iterations.within_timeframe(start_date, due_date).exists?
errors.add(:base, s_("Iteration|Dates cannot overlap with other existing Iterations"))
end
# ensure dates are in the future
def future_date
if start_date_changed?
errors.add(:start_date, s_("Iteration|cannot be in the past")) if start_date < Date.today
errors.add(:start_date, s_("Iteration|cannot be more than 500 years in the future")) if start_date > 500.years.from_now
end
if due_date_changed?
errors.add(:due_date, s_("Iteration|cannot be in the past")) if due_date < Date.today
errors.add(:due_date, s_("Iteration|cannot be more than 500 years in the future")) if due_date > 500.years.from_now
end
end
end
......@@ -18,6 +18,7 @@ class Milestone < ApplicationRecord
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
scope :active, -> { with_state(:active) }
scope :started, -> { active.where('milestones.start_date <= CURRENT_DATE') }
scope :not_started, -> { active.where('milestones.start_date > CURRENT_DATE') }
scope :not_upcoming, -> do
......
# frozen_string_literal: true
class SprintRenameStateToStateEnum < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
rename_column_concurrently :sprints, :state, :state_enum
end
def down
undo_rename_column_concurrently :sprints, :state, :state_enum
end
end
# frozen_string_literal: true
class SprintMakeStateEnumNotNullAndDefault < ActiveRecord::Migration[6.0]
DOWNTIME = false
def up
change_column_default :sprints, :state_enum, from: 0, to: 1
change_column_null :sprints, :state_enum, false, 1
end
def down
change_column_null :sprints, :state_enum, true
change_column_default :sprints, :state_enum, from: 1, to: nil
end
end
# frozen_string_literal: true
class CleanupSprintsStateRename < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
cleanup_concurrent_column_rename :sprints, :state, :state_enum
end
def down
undo_cleanup_concurrent_column_rename :sprints, :state, :state_enum
end
end
......@@ -6242,11 +6242,11 @@ CREATE TABLE public.sprints (
group_id bigint,
iid integer NOT NULL,
cached_markdown_version integer,
state smallint,
title text NOT NULL,
title_html text,
description text,
description_html text,
state_enum smallint DEFAULT 1 NOT NULL,
CONSTRAINT sprints_must_belong_to_project_or_group CHECK ((((project_id <> NULL::bigint) AND (group_id IS NULL)) OR ((group_id <> NULL::bigint) AND (project_id IS NULL)))),
CONSTRAINT sprints_title CHECK ((char_length(title) <= 255))
);
......@@ -13789,6 +13789,8 @@ COPY "schema_migrations" (version) FROM STDIN;
20200424101920
20200424135319
20200427064130
20200429001827
20200429002150
20200429015603
20200429181335
20200429181955
......@@ -13812,6 +13814,7 @@ COPY "schema_migrations" (version) FROM STDIN;
20200511162057
20200511162115
20200512085150
20200512164334
20200513234502
20200513235347
20200513235532
......
......@@ -1115,6 +1115,61 @@ type CreateImageDiffNotePayload {
note: Note
}
"""
Autogenerated input type of CreateIteration
"""
input CreateIterationInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The description of the iteration
"""
description: String
"""
The end date of the iteration
"""
dueDate: String
"""
The target group for the iteration
"""
groupPath: ID!
"""
The start date of the iteration
"""
startDate: String
"""
The title of the iteration
"""
title: String
}
"""
Autogenerated return type of CreateIteration
"""
type CreateIterationPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
The created iteration
"""
iteration: Iteration
}
"""
Autogenerated input type of CreateNote
"""
......@@ -3186,6 +3241,11 @@ type EpicIssue implements Noteable {
"""
iid: ID!
"""
Iteration of the issue
"""
iteration: Iteration
"""
Labels of the issue
"""
......@@ -4075,6 +4135,53 @@ type Group {
updatedBefore: Time
): IssueConnection
"""
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
"""
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
"""
Indicates if Large File Storage (LFS) is enabled for namespace
"""
......@@ -4536,6 +4643,11 @@ type Issue implements Noteable {
"""
iid: ID!
"""
Iteration of the issue
"""
iteration: Iteration
"""
Labels of the issue
"""
......@@ -4872,6 +4984,51 @@ type IssueSetDueDatePayload {
issue: Issue
}
"""
Autogenerated input type of IssueSetIteration
"""
input IssueSetIterationInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The iid of the issue to mutate
"""
iid: String!
"""
The iteration to assign to the issue.
"""
iterationId: ID
"""
The project the issue to mutate is in
"""
projectPath: ID!
}
"""
Autogenerated return type of IssueSetIteration
"""
type IssueSetIterationPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
The issue after mutation
"""
issue: Issue
}
"""
Autogenerated input type of IssueSetWeight
"""
......@@ -5006,6 +5163,107 @@ enum IssueState {
opened
}
"""
Represents an iteration object.
"""
type Iteration {
"""
Timestamp of iteration creation
"""
createdAt: Time!
"""
Description of the iteration
"""
description: String
"""
Timestamp of the iteration due date
"""
dueDate: Time
"""
ID of the iteration
"""
id: ID!
"""
Timestamp of the iteration start date
"""
startDate: Time
"""
State of the iteration
"""
state: IterationState!
"""
Title of the iteration
"""
title: String!
"""
Timestamp of last iteration update
"""
updatedAt: Time!
"""
Web path of the iteration
"""
webPath: String!
"""
Web URL of the iteration
"""
webUrl: String!
}
"""
The connection type for Iteration.
"""
type IterationConnection {
"""
A list of edges.
"""
edges: [IterationEdge]
"""
A list of nodes.
"""
nodes: [Iteration]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type IterationEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: Iteration
}
"""
State of a GitLab iteration
"""
enum IterationState {
all
closed
opened
started
upcoming
}
"""
Represents untyped JSON
"""
......@@ -6253,6 +6511,7 @@ type Mutation {
createDiffNote(input: CreateDiffNoteInput!): CreateDiffNotePayload
createEpic(input: CreateEpicInput!): CreateEpicPayload
createImageDiffNote(input: CreateImageDiffNoteInput!): CreateImageDiffNotePayload
createIteration(input: CreateIterationInput!): CreateIterationPayload
createNote(input: CreateNoteInput!): CreateNotePayload
createRequirement(input: CreateRequirementInput!): CreateRequirementPayload
createSnippet(input: CreateSnippetInput!): CreateSnippetPayload
......@@ -6266,6 +6525,7 @@ type Mutation {
epicTreeReorder(input: EpicTreeReorderInput!): EpicTreeReorderPayload
issueSetConfidential(input: IssueSetConfidentialInput!): IssueSetConfidentialPayload
issueSetDueDate(input: IssueSetDueDateInput!): IssueSetDueDatePayload
issueSetIteration(input: IssueSetIterationInput!): IssueSetIterationPayload
issueSetWeight(input: IssueSetWeightInput!): IssueSetWeightPayload
jiraImportStart(input: JiraImportStartInput!): JiraImportStartPayload
markAsSpamSnippet(input: MarkAsSpamSnippetInput!): MarkAsSpamSnippetPayload
......
......@@ -204,6 +204,16 @@ Autogenerated return type of CreateImageDiffNote
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `note` | Note | The note after mutation |
## CreateIterationPayload
Autogenerated return type of CreateIteration
| Name | Type | Description |
| --- | ---- | ---------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `iteration` | Iteration | The created iteration |
## CreateNotePayload
Autogenerated return type of CreateNote
......@@ -521,6 +531,7 @@ Relationship between an epic and an issue
| `healthStatus` | HealthStatus | Current health status. Returns null if `save_issuable_health_status` feature flag is disabled. |
| `id` | ID | Global ID of the epic-issue relation |
| `iid` | ID! | Internal ID of the issue |
| `iteration` | Iteration | Iteration of the issue |
| `milestone` | Milestone | Milestone of the issue |
| `reference` | String! | Internal reference of the issue. Returned in shortened format by default |
| `relationPath` | String | URI path of the epic-issue relation |
......@@ -660,6 +671,7 @@ Autogenerated return type of EpicTreeReorder
| `epic` | Epic | Epic to which this issue belongs |
| `healthStatus` | HealthStatus | Current health status. Returns null if `save_issuable_health_status` feature flag is disabled. |
| `iid` | ID! | Internal ID of the issue |
| `iteration` | Iteration | Iteration of the issue |
| `milestone` | Milestone | Milestone of the issue |
| `reference` | String! | Internal reference of the issue. Returned in shortened format by default |
| `relativePosition` | Int | Relative position of the issue (used for positioning in epic tree and issue boards) |
......@@ -713,6 +725,16 @@ Autogenerated return type of IssueSetDueDate
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `issue` | Issue | The issue after mutation |
## IssueSetIterationPayload
Autogenerated return type of IssueSetIteration
| Name | Type | Description |
| --- | ---- | ---------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `issue` | Issue | The issue after mutation |
## IssueSetWeightPayload
Autogenerated return type of IssueSetWeight
......@@ -723,6 +745,23 @@ Autogenerated return type of IssueSetWeight
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `issue` | Issue | The issue after mutation |
## Iteration
Represents an iteration object.
| Name | Type | Description |
| --- | ---- | ---------- |
| `createdAt` | Time! | Timestamp of iteration creation |
| `description` | String | Description of the iteration |
| `dueDate` | Time | Timestamp of the iteration due date |
| `id` | ID! | ID of the iteration |
| `startDate` | Time | Timestamp of the iteration start date |
| `state` | IterationState! | State of the iteration |
| `title` | String! | Title of the iteration |
| `updatedAt` | Time! | Timestamp of last iteration update |
| `webPath` | String! | Web path of the iteration |
| `webUrl` | String! | Web URL of the iteration |
## JiraImport
| Name | Type | Description |
......
# frozen_string_literal: true
class Groups::IterationsController < Groups::ApplicationController
before_action :check_iterations_available!
before_action :authorize_show_iteration!, only: [:index, :show]
before_action :authorize_create_iteration!, only: :new
def index; end
def show; end
def new; end
private
def check_iterations_available!
return render_404 unless group.feature_available?(:iterations)
end
def authorize_create_iteration!
return render_404 unless can?(current_user, :create_iteration, group)
end
def authorize_show_iteration!
return render_404 unless can?(current_user, :read_iteration, group)
end
end
......@@ -21,6 +21,10 @@ module EE
max_page_size: 2000,
resolver: ::Resolvers::EpicsResolver
field :iterations, ::Types::IterationType.connection_type, null: true,
description: 'Find iterations',
resolver: ::Resolvers::IterationsResolver
field :timelogs, ::Types::TimelogType.connection_type, null: false,
description: 'Time logged in issues by group members',
complexity: 5,
......
......@@ -9,6 +9,10 @@ module EE
field :epic, ::Types::EpicType, null: true,
description: 'Epic to which this issue belongs'
field :iteration, ::Types::IterationType, null: true,
description: 'Iteration of the issue',
resolve: -> (obj, _args, _ctx) { ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Iteration, obj.sprint_id).find }
field :weight, GraphQL::INT_TYPE, null: true,
description: 'Weight of the issue',
resolve: -> (obj, _args, _ctx) { obj.supports_weight? ? obj.weight : nil }
......
......@@ -6,12 +6,14 @@ module EE
extend ActiveSupport::Concern
prepended do
mount_mutation ::Mutations::Issues::SetIteration
mount_mutation ::Mutations::Issues::SetWeight
mount_mutation ::Mutations::EpicTree::Reorder
mount_mutation ::Mutations::Epics::Update
mount_mutation ::Mutations::Epics::Create
mount_mutation ::Mutations::Epics::SetSubscription
mount_mutation ::Mutations::Epics::AddIssue
mount_mutation ::Mutations::Iterations::Create
mount_mutation ::Mutations::RequirementsManagement::CreateRequirement
mount_mutation ::Mutations::RequirementsManagement::UpdateRequirement
mount_mutation ::Mutations::Vulnerabilities::Dismiss
......
# frozen_string_literal: true
module Mutations
module Issues
class SetIteration < Base
graphql_name 'IssueSetIteration'
argument :iteration_id,
GraphQL::ID_TYPE,
required: false,
loads: Types::IterationType,
description: <<~DESC
The iteration to assign to the issue.
DESC
def resolve(project_path:, iid:, iteration: nil)
issue = authorized_find!(project_path: project_path, iid: iid)
project = issue.project
::Issues::UpdateService.new(project, current_user, iteration: iteration)
.execute(issue)
{
issue: issue,
errors: issue.errors.full_messages
}
end
end
end
end
# frozen_string_literal: true
module Mutations
module Iterations
class Create < BaseMutation
include Mutations::ResolvesGroup
graphql_name 'CreateIteration'
authorize :create_iteration
field :iteration,
Types::IterationType,
null: true,
description: 'The created iteration'
argument :group_path, GraphQL::ID_TYPE,
required: true,
description: "The target group for the iteration"
argument :title,
GraphQL::STRING_TYPE,
required: false,
description: 'The title of the iteration'
argument :description,
GraphQL::STRING_TYPE,
required: false,
description: 'The description of the iteration'
argument :start_date,
GraphQL::STRING_TYPE,
required: false,
description: 'The start date of the iteration'
argument :due_date,
GraphQL::STRING_TYPE,
required: false,
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
response_object = response.payload[:iteration] if response.success?
response_errors = response.error? ? response.payload[:errors].full_messages : []
{
iteration: response_object,
errors: response_errors
}
end
private
def find_object(group_path:)
resolve_group(full_path: group_path)
end
def validate_arguments!(args)
if args.empty?
raise Gitlab::Graphql::Errors::ArgumentError,
'The list of iteration attributes is empty'
end
end
end
end
end
# frozen_string_literal: true
module Resolvers
class IterationsResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
include TimeFrameArguments
argument :state, Types::IterationStateEnum,
required: false,
description: 'Filter iterations by state'
argument :title, GraphQL::STRING_TYPE,
required: false,
description: 'Fuzzy search by title'
type Types::IterationType, null: true
def resolve(**args)
validate_timeframe_params!(args)
authorize!
iterations = IterationsFinder.new(iterations_finder_params(args)).execute
Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(iterations)
end
private
def iterations_finder_params(args)
{
state: args[:state] || 'all',
start_date: args[:start_date],
end_date: args[:end_date],
search_title: args[:title]
}.merge(parent_id_parameter)
end
def parent
@parent ||= object.respond_to?(:sync) ? object.sync : object
end
def parent_id_parameter
if parent.is_a?(Group)
{ group_ids: parent.id }
elsif parent.is_a?(Project)
{ project_ids: parent.id }
end
end
# IterationsFinder does not check for current_user permissions,
# so for now we need to keep it here.
def authorize!
Ability.allowed?(context[:current_user], :read_iteration, parent) || raise_resource_not_available_error!
end
end
end
# frozen_string_literal: true
module Types
class IterationStateEnum < BaseEnum
graphql_name 'IterationState'
description 'State of a GitLab iteration'
value 'upcoming'
value 'started'
value 'opened'
value 'closed'
value 'all'
end
end
# frozen_string_literal: true
module Types
class IterationType < BaseObject
graphql_name 'Iteration'
description 'Represents an iteration object.'
present_using IterationPresenter
authorize :read_iteration
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the iteration'
field :title, GraphQL::STRING_TYPE, null: false,
description: 'Title of the iteration'
field :description, GraphQL::STRING_TYPE, null: true,
description: 'Description of the iteration'
field :state, Types::IterationStateEnum, null: false,
description: 'State of the iteration'
field :web_path, GraphQL::STRING_TYPE, null: false, method: :iteration_path,
description: 'Web path of the iteration'
field :web_url, GraphQL::STRING_TYPE, null: false, method: :iteration_url,
description: 'Web URL of the iteration'
field :due_date, Types::TimeType, null: true,
description: 'Timestamp of the iteration due date'
field :start_date, Types::TimeType, null: true,
description: 'Timestamp of the iteration start date'
field :created_at, Types::TimeType, null: false,
description: 'Timestamp of iteration creation'
field :updated_at, Types::TimeType, null: false,
description: 'Timestamp of last iteration update'
end
end
# frozen_string_literal: true
class SprintPolicy < BasePolicy
class IterationPolicy < BasePolicy
delegate { @subject.resource_parent }
end
# frozen_string_literal: true
class IterationPresenter < Gitlab::View::Presenter::Delegated
presents :iteration
def iteration_path
url_builder.build(iteration, only_path: true)
end
def iteration_url
url_builder.build(iteration)
end
end
- page_title _("Iterations")
.js-iterations-list{ data: { group_full_path: @group.full_path, can_admin: can?(current_user, :admin_iteration, @group) } }
- add_to_breadcrumbs _("Iterations"), group_iterations_path(@group)
- breadcrumb_title _("New")
- page_title _("Iterations")
%h3.page-title
= _("New Iteration")
.js-iteration-new{ data: { group_full_path: @group.full_path } }
- add_to_breadcrumbs _("Iterations"), group_iterations_path(@group)
- breadcrumb_title params[:id]
- page_title _("Iterations")
.js-iteration{ data: { group_full_path: @group.full_path, iteration_id: params[:iteration_id] } }
......@@ -109,6 +109,8 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
end
end
resources :iterations, only: [:index, :new, :show], constraints: { id: /\d+/ }
resources :issues, only: [] do
collection do
post :bulk_update
......
......@@ -13,6 +13,8 @@ module EE
case object.itself
when Epic
instance.group_epic_url(object.group, object, **options)
when Iteration
instance.group_iteration_url(object.group, object, **options)
when Vulnerability
instance.project_security_vulnerability_url(object.project, object, **options)
else
......
......@@ -7,10 +7,10 @@ describe IterationsFinder do
let_it_be(:group) { create(:group) }
let_it_be(:project_1) { create(:project, namespace: group) }
let_it_be(:project_2) { create(:project, namespace: group) }
let!(:started_group_iteration) { create(:iteration, group: group, title: 'one test', start_date: now - 1.day, due_date: now) }
let!(:upcoming_group_iteration) { create(:iteration, group: group, start_date: now + 1.day, due_date: now + 2.days) }
let!(:iteration_from_project_1) { create(:iteration, project: project_1, state: ::Iteration::STATE_ID_MAP[:active], start_date: now + 2.days, due_date: now + 3.days) }
let!(:iteration_from_project_2) { create(:iteration, project: project_2, state: ::Iteration::STATE_ID_MAP[:active], start_date: now + 4.days, due_date: now + 5.days) }
let!(:started_group_iteration) { create(:started_iteration, :skip_future_date_validation, group: group, title: 'one test', start_date: now - 1.day, due_date: now) }
let!(:upcoming_group_iteration) { create(:iteration, group: group, start_date: 1.day.from_now, due_date: 2.days.from_now) }
let!(:iteration_from_project_1) { create(:started_iteration, project: project_1, start_date: 2.days.from_now, due_date: 3.days.from_now) }
let!(:iteration_from_project_2) { create(:started_iteration, project: project_2, start_date: 4.days.from_now, due_date: 5.days.from_now) }
let(:project_ids) { [project_1.id, project_2.id] }
subject { described_class.new(params).execute }
......@@ -39,7 +39,7 @@ describe IterationsFinder do
end
it 'orders iterations by due date' do
iteration = create(:iteration, group: group, due_date: now - 2.days)
iteration = create(:iteration, :skip_future_date_validation, group: group, start_date: now - 3.days, due_date: now - 2.days)
expect(subject.first).to eq(iteration)
expect(subject.second).to eq(started_group_iteration)
......@@ -61,8 +61,14 @@ describe IterationsFinder do
iteration_from_project_1.close
end
it 'filters by active state' do
params[:state] = 'active'
it 'filters by started state' do
params[:state] = 'started'
expect(subject).to contain_exactly(iteration_from_project_2)
end
it 'filters by opened state' do
params[:state] = 'opened'
expect(subject).to contain_exactly(upcoming_group_iteration, iteration_from_project_2)
end
......@@ -87,21 +93,21 @@ describe IterationsFinder do
context 'by timeframe' do
it 'returns iterations with start_date and due_date between timeframe' do
params.merge!(start_date: now - 1.day, end_date: now + 3.days)
params.merge!(start_date: now - 1.day, end_date: 3.days.from_now)
expect(subject).to match_array([started_group_iteration, upcoming_group_iteration, iteration_from_project_1])
end
it 'returns iterations which start before the timeframe' do
iteration = create(:iteration, project: project_2, start_date: now - 5.days)
iteration = create(:iteration, :skip_future_date_validation, project: project_2, start_date: now - 5.days)
params.merge!(start_date: now - 3.days, end_date: now - 2.days)
expect(subject).to match_array([iteration])
end
it 'returns iterations which end after the timeframe' do
iteration = create(:iteration, project: project_2, due_date: now + 6.days)
params.merge!(start_date: now + 6.days, end_date: now + 7.days)
iteration = create(:iteration, project: project_2, start_date: 6.days.from_now, due_date: 2.weeks.from_now)
params.merge!(start_date: 6.days.from_now, end_date: 7.days.from_now)
expect(subject).to match_array([iteration])
end
......
......@@ -9,6 +9,7 @@ describe GitlabSchema.types['Group'] do
it { expect(described_class).to have_graphql_field(:epic) }
end
it { expect(described_class).to have_graphql_field(:iterations) }
it { expect(described_class).to have_graphql_field(:groupTimelogsEnabled) }
it { expect(described_class).to have_graphql_field(:timelogs, complexity: 5) }
it { expect(described_class).to have_graphql_field(:vulnerabilities) }
......
# frozen_string_literal: true
require 'spec_helper'
describe Mutations::Issues::SetIteration do
let(:issue) { create(:issue) }
let(:user) { create(:user) }
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
describe '#resolve' do
let(:iteration) { create(:iteration, project: issue.project) }
let(:mutated_issue) { subject[:issue] }
subject { mutation.resolve(project_path: issue.project.full_path, iid: issue.iid, iteration: iteration) }
it 'raises an error if the resource is not accessible to the user' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
context 'when the user can update the issue' do
before do
issue.project.add_developer(user)
end
it 'returns the issue with the iteration' do
expect(mutated_issue).to eq(issue)
expect(mutated_issue.iteration).to eq(iteration)
expect(subject[:errors]).to be_empty
end
it 'returns errors issue could not be updated' do
# Make the issue invalid
issue.update_column(:author_id, nil)
expect(subject[:errors]).not_to be_empty
end
context 'when passing iteration_id as nil' do
let(:iteration) { nil }
it 'removes the iteration' do
issue.update!(iteration: create(:iteration, project: issue.project))
expect(mutated_issue.iteration).to eq(nil)
end
it 'does not do anything if the issue already does not have a iteration' do
expect(mutated_issue.iteration).to eq(nil)
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::IterationsResolver do
include GraphqlHelpers
describe '#resolve' do
let_it_be(:current_user) { create(:user) }
context 'for group iterations' do
let_it_be(:now) { Time.now }
let_it_be(:group) { create(:group, :private) }
def resolve_group_iterations(args = {}, context = { current_user: current_user })
resolve(described_class, obj: group, args: args, ctx: context)
end
before do
group.add_developer(current_user)
end
it 'calls IterationsFinder#execute' do
expect_next_instance_of(IterationsFinder) do |finder|
expect(finder).to receive(:execute)
end
resolve_group_iterations
end
context 'without parameters' do
it 'calls IterationsFinder to retrieve all iterations' do
expect(IterationsFinder).to receive(:new)
.with(group_ids: group.id, state: 'all', start_date: nil, end_date: nil, search_title: nil)
.and_call_original
resolve_group_iterations
end
end
context 'with parameters' do
it 'calls IterationsFinder with correct parameters' do
start_date = now
end_date = start_date + 1.hour
search = 'wow'
expect(IterationsFinder).to receive(:new)
.with(group_ids: group.id, state: 'closed', start_date: start_date, end_date: end_date, search_title: search)
.and_call_original
resolve_group_iterations(start_date: start_date, end_date: end_date, state: 'closed', title: search)
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_iterations(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
expect do
resolve_group_iterations(start_date: 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_group_iterations(end_date: now)
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, /Both startDate and endDate/)
end
end
end
context 'when user cannot read iterations' do
it 'raises error' do
unauthorized_user = create(:user)
expect do
resolve_group_iterations({}, { current_user: unauthorized_user })
end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
end
end
end
......@@ -5,6 +5,8 @@ require 'spec_helper'
describe GitlabSchema.types['Issue'] do
it { expect(described_class).to have_graphql_field(:epic) }
it { expect(described_class).to have_graphql_field(:iteration) }
it { expect(described_class).to have_graphql_field(:weight) }
it { expect(described_class).to have_graphql_field(:health_status) }
......
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['Iteration'] do
it { expect(described_class.graphql_name).to eq('Iteration') }
it { expect(described_class).to require_graphql_authorizations(:read_iteration) }
end
# frozen_string_literal: true
require 'spec_helper'
describe 'Creating an Iteration' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:group) { create(:group) }
let(:start_date) { Time.now.strftime('%F') }
let(:end_date) { 1.day.from_now.strftime('%F') }
let(:attributes) do
{
title: 'title',
description: 'some description',
start_date: start_date,
due_date: end_date
}
end
let(:mutation) do
params = { group_path: group.full_path }.merge(attributes)
graphql_mutation(:create_iteration, params)
end
def mutation_response
graphql_mutation_response(:create_iteration)
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 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']
it 'does not create iteration' do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Iteration, :count)
end
end
context 'when the user has permission' do
before do
group.add_developer(current_user)
end
context 'when iterations are 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 are enabled' do
before do
stub_licensed_features(iterations: true)
end
it 'creates the iteration' 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
context 'when there are ActiveRecord validation errors' do
let(:attributes) { { title: '' } }
it_behaves_like 'a mutation that returns errors in the response',
errors: ["Title can't be blank", "Start date can't be blank", "Due date can't be blank"]
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 list of attributes is empty' do
let(:attributes) { {} }
it_behaves_like 'a mutation that returns top-level errors',
errors: ['The list of iteration attributes is empty']
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
......@@ -19,7 +19,9 @@ describe Iterations::CreateService do
let(:params) do
{
title: 'v2.1.9',
description: 'Patch release to fix security issue'
description: 'Patch release to fix security issue',
start_date: Time.now.to_s,
due_date: 1.day.from_now.to_s
}
end
......@@ -44,7 +46,7 @@ describe Iterations::CreateService do
it 'does not create an iteration but returns errors' do
expect(response.error?).to be_truthy
expect(errors.messages).to match({ title: ["can't be blank"] })
expect(errors.messages).to match({ title: ["can't be blank"], due_date: ["can't be blank"], start_date: ["can't be blank"] })
end
end
......
......@@ -40,6 +40,8 @@ module Gitlab
end
if order_list.count > 2
# Keep in mind an order clause for primary key is added if one is not present
# lib/gitlab/graphql/pagination/keyset/connection.rb:97
raise ArgumentError.new('A maximum of 2 ordering fields are allowed')
end
......
......@@ -11957,6 +11957,18 @@ msgstr ""
msgid "It's you"
msgstr ""
msgid "Iterations"
msgstr ""
msgid "Iteration|Dates cannot overlap with other existing Iterations"
msgstr ""
msgid "Iteration|cannot be in the past"
msgstr ""
msgid "Iteration|cannot be more than 500 years in the future"
msgstr ""
msgid "Jaeger URL"
msgstr ""
......@@ -13907,6 +13919,9 @@ msgid_plural "New Issues"
msgstr[0] ""
msgstr[1] ""
msgid "New Iteration"
msgstr ""
msgid "New Jira import"
msgstr ""
......
# frozen_string_literal: true
FactoryBot.define do
sequence(:sequential_date) do |n|
n.days.from_now
end
factory :iteration do
title
start_date { generate(:sequential_date) }
due_date { generate(:sequential_date) }
transient do
project { nil }
......@@ -12,17 +18,22 @@ FactoryBot.define do
resource_parent { nil }
end
trait :active do
state { Iteration::STATE_ID_MAP[:active] }
trait :upcoming do
state_enum { Iteration::STATE_ENUM_MAP[:upcoming] }
end
trait :started do
state_enum { Iteration::STATE_ENUM_MAP[:started] }
end
trait :closed do
state { Iteration::STATE_ID_MAP[:closed] }
state_enum { Iteration::STATE_ENUM_MAP[:closed] }
end
trait :with_dates do
start_date { Date.new(2000, 1, 1) }
due_date { Date.new(2000, 1, 30) }
trait(:skip_future_date_validation) do
after(:stub, :build) do |iteration|
iteration.skip_future_date_validation = true
end
end
after(:build, :stub) do |iteration, evaluator|
......@@ -42,7 +53,8 @@ FactoryBot.define do
end
end
factory :active_iteration, traits: [:active]
factory :upcoming_iteration, traits: [:upcoming]
factory :started_iteration, traits: [:started]
factory :closed_iteration, traits: [:closed]
end
end
......@@ -3,14 +3,14 @@
require 'spec_helper'
describe Iteration do
let_it_be(:project) { create(:project) }
let_it_be(:group) { create(:group) }
it_behaves_like 'a timebox', :iteration do
let(:timebox_table_name) { described_class.table_name.to_sym }
end
describe "#iid" do
let!(:project) { create(:project) }
let!(:group) { create(:group) }
it "is properly scoped on project and group" do
iteration1 = create(:iteration, project: project)
iteration2 = create(:iteration, project: project)
......@@ -35,4 +35,136 @@ describe Iteration do
expect(got).to eq(want)
end
end
context 'Validations' do
subject { build(:iteration, group: group, start_date: start_date, due_date: due_date) }
describe '#dates_do_not_overlap' do
let_it_be(:existing_iteration) { create(:iteration, group: group, start_date: 4.days.from_now, due_date: 1.week.from_now) }
context 'when no Iteration dates overlap' do
let(:start_date) { 2.weeks.from_now }
let(:due_date) { 3.weeks.from_now }
it { is_expected.to be_valid }
end
context 'when dates overlap' do
context 'same group' do
context 'when start_date is in range' do
let(:start_date) { 5.days.from_now }
let(:due_date) { 3.weeks.from_now }
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations')
end
end
context 'when end_date is in range' do
let(:start_date) { Time.now }
let(:due_date) { 6.days.from_now }
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations')
end
end
context 'when both overlap' do
let(:start_date) { 5.days.from_now }
let(:due_date) { 6.days.from_now }
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations')
end
end
end
context 'different group' do
let(:start_date) { 5.days.from_now }
let(:due_date) { 6.days.from_now }
let(:group) { create(:group) }
it { is_expected.to be_valid }
end
end
end
describe '#future_date' do
context 'when dates are in the future' do
let(:start_date) { Time.now }
let(:due_date) { 1.week.from_now }
it { is_expected.to be_valid }
end
context 'when start_date is in the past' do
let(:start_date) { 1.week.ago }
let(:due_date) { 1.week.from_now }
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:start_date]).to include('cannot be in the past')
end
end
context 'when due_date is in the past' do
let(:start_date) { Time.now }
let(:due_date) { 1.week.ago }
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:due_date]).to include('cannot be in the past')
end
end
context 'when start_date is over 500 years in the future' do
let(:start_date) { 501.years.from_now }
let(:due_date) { Time.now }
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:start_date]).to include('cannot be more than 500 years in the future')
end
end
context 'when due_date is over 500 years in the future' do
let(:start_date) { Time.now }
let(:due_date) { 501.years.from_now }
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:due_date]).to include('cannot be more than 500 years in the future')
end
end
end
end
describe '.within_timeframe' do
let_it_be(:now) { Time.now }
let_it_be(:project) { create(:project, :empty_repo) }
let_it_be(:iteration_1) { create(:iteration, project: project, start_date: now, due_date: 1.day.from_now) }
let_it_be(:iteration_2) { create(:iteration, project: project, start_date: 2.days.from_now, due_date: 3.days.from_now) }
let_it_be(:iteration_3) { create(:iteration, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) }
it 'returns iterations with start_date and/or end_date between timeframe' do
iterations = described_class.within_timeframe(2.days.from_now, 3.days.from_now)
expect(iterations).to match_array([iteration_2])
end
it 'returns iterations which starts before the timeframe' do
iterations = described_class.within_timeframe(1.day.from_now, 3.days.from_now)
expect(iterations).to match_array([iteration_1, iteration_2])
end
it 'returns iterations which ends after the timeframe' do
iterations = described_class.within_timeframe(3.days.from_now, 5.days.from_now)
expect(iterations).to match_array([iteration_2, iteration_3])
end
end
end
......@@ -84,13 +84,14 @@ RSpec.shared_examples 'a timebox' do |timebox_type|
describe "#uniqueness_of_title" do
context "per project" do
it "does not accept the same title in a project twice" do
new_timebox = described_class.new(project: timebox.project, title: timebox.title)
new_timebox = timebox.dup
expect(new_timebox).not_to be_valid
end
it "accepts the same title in another project" do
project = create(:project)
new_timebox = described_class.new(project: project, title: timebox.title)
new_timebox = timebox.dup
new_timebox.project = project
expect(new_timebox).to be_valid
end
......@@ -231,15 +232,6 @@ RSpec.shared_examples 'a timebox' do |timebox_type|
end
end
it_behaves_like 'within_timeframe scope' do
let_it_be(:now) { Time.now }
let_it_be(:project) { create(:project, :empty_repo) }
let_it_be(:resource_1) { create(timebox_type, project: project, start_date: now - 1.day, due_date: now + 1.day) }
let_it_be(:resource_2) { create(timebox_type, project: project, start_date: now + 2.days, due_date: now + 3.days) }
let_it_be(:resource_3) { create(timebox_type, project: project, due_date: now) }
let_it_be(:resource_4) { create(timebox_type, project: project, start_date: now) }
end
describe '#to_ability_name' do
it 'returns timebox' do
timebox = build(timebox_type)
......
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