Commit e39796f7 authored by Alexandru Croitor's avatar Alexandru Croitor

Move iteration model code to EE where it belongs

Iterations are an EE only feature, but for some reason most of the
iteration model code was in FOSS codebase.
parent 2050e382
......@@ -33,8 +33,6 @@ class Group < Namespace
has_many :members_and_requesters, as: :source, class_name: 'GroupMember'
has_many :milestones
has_many :iterations
has_many :iterations_cadences, class_name: 'Iterations::Cadence'
has_many :services
has_many :shared_group_links, foreign_key: :shared_with_group_id, class_name: 'GroupGroupLink'
has_many :shared_with_group_links, foreign_key: :shared_group_id, class_name: 'GroupGroupLink'
......
# frozen_string_literal: true
# Placeholder class for model that is implemented in EE
class Iteration < ApplicationRecord
self.table_name = 'sprints'
attr_accessor :skip_future_date_validation
attr_accessor :skip_project_validation
STATE_ENUM_MAP = {
upcoming: 1,
started: 2,
closed: 3
}.with_indifferent_access.freeze
include AtomicInternalId
include Timebox
belongs_to :project
belongs_to :group
belongs_to :iterations_cadence, class_name: 'Iterations::Cadence', foreign_key: :iterations_cadence_id, inverse_of: :iterations
has_internal_id :iid, scope: :project
has_internal_id :iid, scope: :group
validates :start_date, presence: true
validates :due_date, presence: true
validates :iterations_cadence, presence: true, unless: -> { project_id.present? }
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
validate :no_project, unless: :skip_project_validation
validate :validate_group
before_validation :set_iterations_cadence, unless: -> { project_id.present? }
before_create :set_past_iteration_state
scope :upcoming, -> { with_state(:upcoming) }
scope :started, -> { with_state(:started) }
scope :closed, -> { with_state(:closed) }
scope :within_timeframe, -> (start_date, end_date) do
where('start_date <= ?', end_date).where('due_date >= ?', start_date)
end
scope :start_date_passed, -> { where('start_date <= ?', Date.current).where('due_date >= ?', Date.current) }
scope :due_date_passed, -> { where('due_date < ?', Date.current) }
state_machine :state_enum, initial: :upcoming do
event :start do
transition upcoming: :started
end
event :close do
transition [:upcoming, :started] => :closed
end
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 'upcoming' then iterations.upcoming
when 'opened' then iterations.started.or(iterations.upcoming)
when 'all' then iterations
else raise ArgumentError, "Unknown state filter: #{state}"
end
end
def reference_prefix
'*iteration:'
end
def reference_pattern
nil
end
end
def state
STATE_ENUM_MAP.key(state_enum)
end
def state=(value)
self.state_enum = STATE_ENUM_MAP[value]
end
def resource_parent
group || project
end
private
def parent_group
group || project.group
end
def start_or_due_dates_changed?
start_date_changed? || due_date_changed?
end
# ensure dates do not overlap with other Iterations in the same cadence tree
def dates_do_not_overlap
return unless iterations_cadence
return unless iterations_cadence.iterations.where.not(id: self.id).within_timeframe(start_date, due_date).exists?
# for now we only have a single default cadence within a group just to wrap the iterations into a set.
# once we introduce multiple cadences per group we need to change this message.
# related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/299312
errors.add(:base, s_("Iteration|Dates cannot overlap with other existing Iterations within this group"))
end
def future_date
if start_or_due_dates_changed?
errors.add(:start_date, s_("Iteration|cannot be more than 500 years in the future")) if start_date > 500.years.from_now
errors.add(:due_date, s_("Iteration|cannot be more than 500 years in the future")) if due_date > 500.years.from_now
end
end
def no_project
return unless project_id.present?
errors.add(:project_id, s_("is not allowed. We do not currently support project-level iterations"))
end
def set_past_iteration_state
# if we create an iteration in the past, we set the state to closed right away,
# no need to wait for IterationsUpdateStatusWorker to do so.
self.state = :closed if due_date < Date.current
end
# TODO: this method should be removed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/296099
def set_iterations_cadence
return if iterations_cadence
# For now we support only group iterations
# issue to clarify project iterations: https://gitlab.com/gitlab-org/gitlab/-/issues/299864
return unless group
# we need this as we use the cadence to validate the dates overlap for this iteration,
# so in the case this runs before background migration we need to first set all iterations
# in this group to a cadence before we can validate the dates overlap.
default_cadence = find_or_create_default_cadence
group.iterations.where(iterations_cadence_id: nil).update_all(iterations_cadence_id: default_cadence.id)
self.iterations_cadence = default_cadence
end
def find_or_create_default_cadence
cadence_title = "#{group.name} Iterations"
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)
def self.reference_prefix
'*iteration:'
end
# TODO: remove this as part of https://gitlab.com/gitlab-org/gitlab/-/issues/296100
def validate_group
return unless iterations_cadence
return if iterations_cadence.group_id == group_id
errors.add(:group, s_('is not valid. The iteration group has to match the iteration cadence group.'))
def self.reference_pattern
nil
end
end
Iteration.prepend_if_ee('EE::Iteration')
Iteration.prepend_if_ee('::EE::Iteration')
# frozen_string_literal: true
# Placeholder class for model that is implemented in EE
class Iterations::Cadence < ApplicationRecord
self.table_name = 'iterations_cadences'
belongs_to :group
has_many :iterations, foreign_key: :iterations_cadence_id, inverse_of: :iterations_cadence
validates :title, presence: true
validates :start_date, presence: true
validates :group_id, presence: true
validates :active, presence: true
validates :automatic, presence: true
end
Iterations::Cadence.prepend_if_ee('::EE::Iterations::Cadence')
......@@ -20,7 +20,8 @@ module EE
has_many :epics
has_many :epic_boards, class_name: 'Boards::EpicBoard', inverse_of: :group
has_many :iterations
has_many :iterations_cadences, class_name: 'Iterations::Cadence'
has_one :saml_provider
has_many :scim_identities
has_many :ip_restrictions, autosave: true
......
......@@ -4,6 +4,12 @@ module EE
module Iteration
extend ActiveSupport::Concern
STATE_ENUM_MAP = {
upcoming: 1,
started: 2,
closed: 3
}.with_indifferent_access.freeze
# For Iteration
class Predefined
None = ::Timebox::TimeboxStruct.new('None', 'none', ::Timebox::None.id).freeze
......@@ -14,8 +20,63 @@ module EE
end
prepended do
include AtomicInternalId
include Timebox
attr_accessor :skip_future_date_validation
attr_accessor :skip_project_validation
belongs_to :project
belongs_to :group
belongs_to :iterations_cadence, class_name: '::Iterations::Cadence', foreign_key: :iterations_cadence_id, inverse_of: :iterations
has_many :issues, foreign_key: 'sprint_id'
has_many :merge_requests, foreign_key: 'sprint_id'
has_internal_id :iid, scope: :project
has_internal_id :iid, scope: :group
validates :start_date, presence: true
validates :due_date, presence: true
validates :iterations_cadence, presence: true, unless: -> { project_id.present? }
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
validate :no_project, unless: :skip_project_validation
validate :validate_group
before_validation :set_iterations_cadence, unless: -> { project_id.present? }
before_create :set_past_iteration_state
scope :upcoming, -> { with_state(:upcoming) }
scope :started, -> { with_state(:started) }
scope :closed, -> { with_state(:closed) }
scope :within_timeframe, -> (start_date, end_date) do
where('start_date <= ?', end_date).where('due_date >= ?', start_date)
end
scope :start_date_passed, -> { where('start_date <= ?', Date.current).where('due_date >= ?', Date.current) }
scope :due_date_passed, -> { where('due_date < ?', Date.current) }
state_machine :state_enum, initial: :upcoming do
event :start do
transition upcoming: :started
end
event :close do
transition [:upcoming, :started] => :closed
end
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
class << self
alias_method :with_state, :with_state_enum
alias_method :with_states, :with_state_enums
end
end
class_methods do
......@@ -41,6 +102,29 @@ module EE
def link_reference_pattern
@link_reference_pattern ||= super("iterations", /(?<iteration>\d+)/)
end
def filter_by_state(iterations, state)
case state
when 'closed' then iterations.closed
when 'started' then iterations.started
when 'upcoming' then iterations.upcoming
when 'opened' then iterations.started.or(iterations.upcoming)
when 'all' then iterations
else raise ArgumentError, "Unknown state filter: #{state}"
end
end
end
def state
STATE_ENUM_MAP.key(state_enum)
end
def state=(value)
self.state_enum = STATE_ENUM_MAP[value]
end
def resource_parent
group || project
end
# Show just the title when we manage to find an iteration, without the reference pattern,
......@@ -64,5 +148,74 @@ module EE
id
end
end
def parent_group
group || project.group
end
def start_or_due_dates_changed?
start_date_changed? || due_date_changed?
end
# ensure dates do not overlap with other Iterations in the same cadence tree
def dates_do_not_overlap
return unless iterations_cadence
return unless iterations_cadence.iterations.where.not(id: self.id).within_timeframe(start_date, due_date).exists?
# for now we only have a single default cadence within a group just to wrap the iterations into a set.
# once we introduce multiple cadences per group we need to change this message.
# related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/299312
errors.add(:base, s_("Iteration|Dates cannot overlap with other existing Iterations within this group"))
end
def future_date
if start_or_due_dates_changed?
errors.add(:start_date, s_("Iteration|cannot be more than 500 years in the future")) if start_date > 500.years.from_now
errors.add(:due_date, s_("Iteration|cannot be more than 500 years in the future")) if due_date > 500.years.from_now
end
end
def no_project
return unless project_id.present?
errors.add(:project_id, s_("is not allowed. We do not currently support project-level iterations"))
end
def set_past_iteration_state
# if we create an iteration in the past, we set the state to closed right away,
# no need to wait for IterationsUpdateStatusWorker to do so.
self.state = :closed if due_date < Date.current
end
# TODO: this method should be removed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/296099
def set_iterations_cadence
return if iterations_cadence
# For now we support only group iterations
# issue to clarify project iterations: https://gitlab.com/gitlab-org/gitlab/-/issues/299864
return unless group
# we need this as we use the cadence to validate the dates overlap for this iteration,
# so in the case this runs before background migration we need to first set all iterations
# in this group to a cadence before we can validate the dates overlap.
default_cadence = find_or_create_default_cadence
group.iterations.where(iterations_cadence_id: nil).update_all(iterations_cadence_id: default_cadence.id)
self.iterations_cadence = default_cadence
end
def find_or_create_default_cadence
cadence_title = "#{group.name} Iterations"
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)
end
# TODO: remove this as part of https://gitlab.com/gitlab-org/gitlab/-/issues/296100
def validate_group
return if iterations_cadence&.group_id == group_id
return unless iterations_cadence
errors.add(:group, s_('is not valid. The iteration group has to match the iteration cadence group.'))
end
end
end
# frozen_string_literal: true
module EE
module Iterations
module Cadence
extend ActiveSupport::Concern
prepended do
belongs_to :group
has_many :iterations, foreign_key: :iterations_cadence_id, inverse_of: :iterations_cadence
validates :title, presence: true
validates :start_date, presence: true
validates :group_id, presence: true
validates :active, presence: true
validates :automatic, presence: true
end
end
end
end
This diff is collapsed.
......@@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe Iterations::Cadence do
RSpec.describe ::Iterations::Cadence do
describe 'associations' do
subject { build(:iterations_cadence) }
......
......@@ -28,6 +28,8 @@ RSpec.describe Group do
it { is_expected.to have_many(:provisioned_users) }
it { is_expected.to have_one(:group_merge_request_approval_setting) }
it { is_expected.to have_many(:repository_storage_moves) }
it { is_expected.to have_many(:iterations) }
it { is_expected.to have_many(:iterations_cadences) }
it_behaves_like 'model with wiki' do
let(:container) { create(:group, :nested, :wiki_repo) }
......
......@@ -119,6 +119,56 @@ RSpec.describe Issuable::BulkUpdateService do
it_behaves_like 'does not update issuables attribute', :epic
end
end
describe 'updating iterations' do
shared_examples 'updates iterations' do
it 'succeeds' do
result = bulk_update(issuables, sprint_id: iteration.id)
expect(result.success?).to be_truthy
expect(result.payload[:count]).to eq(issuables.count)
end
it 'updates the issuables iteration' do
bulk_update(issuables, sprint_id: iteration.id)
issuables.each do |issuable|
expect(issuable.reload.iteration).to eq(iteration)
end
end
end
context 'at group level' do
let_it_be(:group) { create(:group) }
let_it_be(:iteration) { create(:iteration, group: group) }
let_it_be(:project) { create(:project, :repository, group: group) }
let(:parent) { group }
context 'when issues' do
let_it_be(:issue1) { create(:issue, project: project) }
let_it_be(:issue2) { create(:issue, project: project) }
let_it_be(:issuables) { [issue1, issue2] }
it_behaves_like 'updates iterations'
end
end
context 'at project level' do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:issuables) { [create(:issue, project: project)] }
let_it_be(:iteration) { create(:iteration, group: group) }
let(:parent) { project }
before do
group.add_reporter(user)
end
it_behaves_like 'updates iterations'
end
end
end
context 'with epics' do
......@@ -178,4 +228,12 @@ RSpec.describe Issuable::BulkUpdateService do
end
end
end
def bulk_update(issuables, extra_params = {})
bulk_update_params = extra_params
.reverse_merge(issuable_ids: Array(issuables).map(&:id).join(','))
type = Array(issuables).first.model_name.param_key
Issuable::BulkUpdateService.new(parent, user, bulk_update_params).execute(type)
end
end
......@@ -25,7 +25,6 @@ RSpec.describe Group do
it { is_expected.to have_many(:clusters).class_name('Clusters::Cluster') }
it { is_expected.to have_many(:container_repositories) }
it { is_expected.to have_many(:milestones) }
it { is_expected.to have_many(:iterations) }
it { is_expected.to have_many(:group_deploy_keys) }
it { is_expected.to have_many(:services) }
it { is_expected.to have_one(:dependency_proxy_setting) }
......
This diff is collapsed.
......@@ -31,23 +31,6 @@ RSpec.describe Issuable::BulkUpdateService do
end
end
shared_examples 'updates iterations' do
it 'succeeds' do
result = bulk_update(issuables, sprint_id: iteration.id)
expect(result.success?).to be_truthy
expect(result.payload[:count]).to eq(issuables.count)
end
it 'updates the issuables iteration' do
bulk_update(issuables, sprint_id: iteration.id)
issuables.each do |issuable|
expect(issuable.reload.iteration).to eq(iteration)
end
end
end
shared_examples 'updating labels' do
def create_issue_with_labels(labels)
create(:labeled_issue, project: project, labels: labels)
......@@ -250,21 +233,6 @@ RSpec.describe Issuable::BulkUpdateService do
it_behaves_like 'updates milestones'
end
describe 'updating iterations' do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:issuables) { [create(:issue, project: project)] }
let_it_be(:iteration) { create(:iteration, group: group) }
let(:parent) { project }
before do
group.add_reporter(user)
end
it_behaves_like 'updates iterations'
end
describe 'updating labels' do
let(:bug) { create(:label, project: project) }
let(:regression) { create(:label, project: project) }
......@@ -347,19 +315,6 @@ RSpec.describe Issuable::BulkUpdateService do
end
end
describe 'updating iterations' do
let_it_be(:iteration) { create(:iteration, group: group) }
let_it_be(:project) { create(:project, :repository, group: group) }
context 'when issues' do
let_it_be(:issue1) { create(:issue, project: project) }
let_it_be(:issue2) { create(:issue, project: project) }
let_it_be(:issuables) { [issue1, issue2] }
it_behaves_like 'updates iterations'
end
end
describe 'updating labels' do
let(:project) { create(:project, :repository, group: group) }
let(:bug) { create(:group_label, group: group) }
......
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