Commit 915ace9f authored by Jan Provaznik's avatar Jan Provaznik

Merge branch '293921-wrap-iterations' into 'master'

Wrap current iterations at a group level into an iteration cadence

See merge request gitlab-org/gitlab!50707
parents 0b660ea5 52bff7f7
......@@ -34,6 +34,7 @@ class Group < Namespace
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'
......
......@@ -16,6 +16,7 @@ class Iteration < ApplicationRecord
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
......@@ -26,6 +27,9 @@ class Iteration < ApplicationRecord
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_create :set_iterations_cadence
scope :upcoming, -> { with_state(:upcoming) }
scope :started, -> { with_state(:started) }
......@@ -135,6 +139,30 @@ class Iteration < ApplicationRecord
errors.add(:project_id, s_("is not allowed. We do not currently support project-level iterations"))
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
self.iterations_cadence = group.iterations_cadences.first || create_default_cadence
end
def create_default_cadence
cadence_title = "#{group.name} Iterations"
Iterations::Cadence.create!(group: group, title: cadence_title, start_date: start_date)
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.'))
end
end
Iteration.prepend_if_ee('EE::Iteration')
# frozen_string_literal: true
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
---
title: Add iterations_cadences table and respective model
merge_request: 50707
author:
type: other
# frozen_string_literal: true
class CreateIterationsCadence < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
create_table_with_constraints :iterations_cadences do |t|
t.references :group, null: false, foreign_key: { to_table: :namespaces, on_delete: :cascade }
t.timestamps_with_timezone null: false
t.date :start_date, null: false
t.date :last_run_date
t.integer :duration_in_weeks
t.integer :iterations_in_advance
t.boolean :active, default: true, null: false
t.boolean :automatic, default: true, null: false
t.text :title, null: false
t.text_limit :title, 255
end
end
def down
drop_table :iterations_cadences if table_exists?(:iterations_cadences)
end
end
# frozen_string_literal: true
class AddIterationsCadenceToSprints < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
INDEX_NAME = 'index_sprints_iterations_cadence_id'
def up
add_column :sprints, :iterations_cadence_id, :integer unless column_exists?(:sprints, :iterations_cadence_id)
add_concurrent_index :sprints, :iterations_cadence_id, name: INDEX_NAME
add_concurrent_foreign_key :sprints, :iterations_cadences, column: :iterations_cadence_id, on_delete: :cascade
end
def down
remove_column :sprints, :iterations_cadence_id if column_exists?(:sprints, :iterations_cadence_id)
end
end
# frozen_string_literal: true
class ScheduleSetDefaultIterationCadences < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
BATCH_SIZE = 1_000
DELAY_INTERVAL = 2.minutes.to_i
MIGRATION_CLASS = 'SetDefaultIterationCadences'
class Iteration < ActiveRecord::Base # rubocop:disable Style/Documentation
include EachBatch
self.table_name = 'sprints'
end
disable_ddl_transaction!
def up
Iteration.select(:group_id).distinct.each_batch(of: BATCH_SIZE, column: :group_id) do |batch, index|
group_ids = batch.pluck(:group_id)
migrate_in(index * DELAY_INTERVAL, MIGRATION_CLASS, group_ids)
end
end
def down
# Not needed
end
end
6488e3542276042f302d79533e3e84c43a4ef471535137bcef11e73a0e4d961f
\ No newline at end of file
7be98c4f62df9fd837f7a547916dd5481c0b4da2d4fc6680b104b2a998be1eed
\ No newline at end of file
26bf4abb73a53f71fbcb8b5cd1ae1e1539ec59e7052b3bbed95ab1de3fda3de7
\ No newline at end of file
......@@ -13544,6 +13544,30 @@ CREATE TABLE issues_self_managed_prometheus_alert_events (
updated_at timestamp with time zone NOT NULL
);
CREATE TABLE iterations_cadences (
id bigint NOT NULL,
group_id bigint NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
start_date date NOT NULL,
last_run_date date,
duration_in_weeks integer,
iterations_in_advance integer,
active boolean DEFAULT true NOT NULL,
automatic boolean DEFAULT true NOT NULL,
title text NOT NULL,
CONSTRAINT check_fedff82d3b CHECK ((char_length(title) <= 255))
);
CREATE SEQUENCE iterations_cadences_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE iterations_cadences_id_seq OWNED BY iterations_cadences.id;
CREATE TABLE jira_connect_installations (
id bigint NOT NULL,
client_key character varying,
......@@ -17357,6 +17381,7 @@ CREATE TABLE sprints (
description text,
description_html text,
state_enum smallint DEFAULT 1 NOT NULL,
iterations_cadence_id integer,
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))
);
......@@ -19068,6 +19093,8 @@ ALTER TABLE ONLY issue_user_mentions ALTER COLUMN id SET DEFAULT nextval('issue_
ALTER TABLE ONLY issues ALTER COLUMN id SET DEFAULT nextval('issues_id_seq'::regclass);
ALTER TABLE ONLY iterations_cadences ALTER COLUMN id SET DEFAULT nextval('iterations_cadences_id_seq'::regclass);
ALTER TABLE ONLY jira_connect_installations ALTER COLUMN id SET DEFAULT nextval('jira_connect_installations_id_seq'::regclass);
ALTER TABLE ONLY jira_connect_subscriptions ALTER COLUMN id SET DEFAULT nextval('jira_connect_subscriptions_id_seq'::regclass);
......@@ -20368,6 +20395,9 @@ ALTER TABLE ONLY sprints
ALTER TABLE ONLY sprints
ADD CONSTRAINT iteration_start_and_due_daterange_project_id_constraint EXCLUDE USING gist (project_id WITH =, daterange(start_date, due_date, '[]'::text) WITH &&) WHERE ((project_id IS NOT NULL));
ALTER TABLE ONLY iterations_cadences
ADD CONSTRAINT iterations_cadences_pkey PRIMARY KEY (id);
ALTER TABLE ONLY jira_connect_installations
ADD CONSTRAINT jira_connect_installations_pkey PRIMARY KEY (id);
......@@ -22441,6 +22471,8 @@ CREATE INDEX index_issues_on_updated_at ON issues USING btree (updated_at);
CREATE INDEX index_issues_on_updated_by_id ON issues USING btree (updated_by_id) WHERE (updated_by_id IS NOT NULL);
CREATE INDEX index_iterations_cadences_on_group_id ON iterations_cadences USING btree (group_id);
CREATE UNIQUE INDEX index_jira_connect_installations_on_client_key ON jira_connect_installations USING btree (client_key);
CREATE INDEX index_jira_connect_subscriptions_on_namespace_id ON jira_connect_subscriptions USING btree (namespace_id);
......@@ -23431,6 +23463,8 @@ CREATE UNIQUE INDEX index_sop_configs_on_project_id ON security_orchestration_po
CREATE UNIQUE INDEX index_sop_configs_on_security_policy_management_project_id ON security_orchestration_policy_configurations USING btree (security_policy_management_project_id);
CREATE INDEX index_sprints_iterations_cadence_id ON sprints USING btree (iterations_cadence_id);
CREATE INDEX index_sprints_on_description_trigram ON sprints USING gin (description gin_trgm_ops);
CREATE INDEX index_sprints_on_due_date ON sprints USING btree (due_date);
......@@ -24268,6 +24302,9 @@ ALTER TABLE ONLY namespaces
ALTER TABLE ONLY epics
ADD CONSTRAINT fk_3654b61b03 FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE ONLY sprints
ADD CONSTRAINT fk_365d1db505 FOREIGN KEY (iterations_cadence_id) REFERENCES iterations_cadences(id) ON DELETE CASCADE;
ALTER TABLE ONLY push_event_payloads
ADD CONSTRAINT fk_36c74129da FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE;
......@@ -26143,6 +26180,9 @@ ALTER TABLE ONLY alert_management_alert_user_mentions
ALTER TABLE ONLY snippet_statistics
ADD CONSTRAINT fk_rails_ebc283ccf1 FOREIGN KEY (snippet_id) REFERENCES snippets(id) ON DELETE CASCADE;
ALTER TABLE ONLY iterations_cadences
ADD CONSTRAINT fk_rails_ece400c55a FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE;
ALTER TABLE ONLY dast_profiles
ADD CONSTRAINT fk_rails_ed1e66fbbf FOREIGN KEY (dast_site_profile_id) REFERENCES dast_site_profiles(id) ON DELETE CASCADE;
......@@ -319,7 +319,9 @@ RSpec.describe Banzai::Filter::IterationReferenceFilter do
project_reference = another_project.to_reference_base(project)
input_text = "See #{project_reference}#{reference}"
iteration.update!(group: another_group)
# we have to update iterations_cadence group first in order to avoid invalid record
iteration.iterations_cadence.update_column(:group_id, another_group.id)
iteration.update_column(:group_id, another_group.id)
doc = reference_filter(input_text)
......@@ -328,7 +330,9 @@ RSpec.describe Banzai::Filter::IterationReferenceFilter do
end
it 'supports parent group references' do
iteration.update!(group: parent_group)
# we have to update iterations_cadence group first in order to avoid invallid record
iteration.iterations_cadence.update_column(:group_id, parent_group.id)
iteration.update_column(:group_id, parent_group.id)
doc = reference_filter("See #{reference}")
......
# frozen_string_literal: true
module Gitlab
module BackgroundMigration
# rubocop:disable Style/Documentation
class SetDefaultIterationCadences
class Iteration < ApplicationRecord
self.table_name = 'sprints'
end
class IterationCadence < ApplicationRecord
self.table_name = 'iterations_cadences'
include BulkInsertSafe
end
class Group < ApplicationRecord
self.table_name = 'namespaces'
end
def perform(*group_ids)
create_iterations_cadences(group_ids)
assign_iterations_cadences(group_ids)
end
private
def create_iterations_cadences(group_ids)
groups_with_cadence = IterationCadence.select(:group_id)
new_cadences = Group.where(id: group_ids).where.not(id: groups_with_cadence).map do |group|
last_iteration = Iteration.where(group_id: group.id).order(:start_date)&.last
next unless last_iteration
time = Time.now
IterationCadence.new(
group_id: group.id,
title: "#{group.name} Iterations",
start_date: last_iteration.start_date,
last_run_date: last_iteration.start_date,
automatic: false,
created_at: time,
updated_at: time
)
end
IterationCadence.bulk_insert!(new_cadences.compact)
end
def assign_iterations_cadences(group_ids)
IterationCadence.where(group_id: group_ids).each do |cadence|
Iteration.where(iterations_cadence_id: nil).where(group_id: cadence.group_id).update_all(iterations_cadence_id: cadence.id)
end
end
end
end
end
......@@ -34933,6 +34933,9 @@ msgstr ""
msgid "is not in the group enforcing Group Managed Account"
msgstr ""
msgid "is not valid. The iteration group has to match the iteration cadence group."
msgstr ""
msgid "is read only"
msgstr ""
......
# frozen_string_literal: true
FactoryBot.define do
sequence(:cadence_sequential_date) do |n|
n.days.from_now
end
factory :iterations_cadence, class: 'Iterations::Cadence' do
title
group
start_date { generate(:cadence_sequential_date) }
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::BackgroundMigration::SetDefaultIterationCadences, schema: 20201231133921 do
let(:namespaces) { table(:namespaces) }
let(:iterations) { table(:sprints) }
let(:iterations_cadences) { table(:iterations_cadences) }
describe '#perform' do
context 'when no iteration cadences exists' do
let!(:group_1) { namespaces.create!(name: 'group 1', path: 'group-1') }
let!(:group_2) { namespaces.create!(name: 'group 2', path: 'group-2') }
let!(:group_3) { namespaces.create!(name: 'group 3', path: 'group-3') }
let!(:iteration_1) { iterations.create!(group_id: group_1.id, iid: 1, title: 'Iteration 1', start_date: 10.days.ago, due_date: 8.days.ago) }
let!(:iteration_2) { iterations.create!(group_id: group_3.id, iid: 1, title: 'Iteration 2', start_date: 10.days.ago, due_date: 8.days.ago) }
let!(:iteration_3) { iterations.create!(group_id: group_3.id, iid: 1, title: 'Iteration 3', start_date: 5.days.ago, due_date: 2.days.ago) }
before do
described_class.new.perform(group_1.id, group_2.id, group_3.id, namespaces.last.id + 1)
end
it 'creates iterations_cadence records for the requested groups' do
expect(iterations_cadences.count).to eq(2)
end
it 'assigns the iteration cadences to the iterations correctly' do
iterations_cadence = iterations_cadences.find_by(group_id: group_1.id)
iteration_records = iterations.where(iterations_cadence_id: iterations_cadence.id)
expect(iterations_cadence.start_date).to eq(iteration_1.start_date)
expect(iterations_cadence.last_run_date).to eq(iteration_1.start_date)
expect(iterations_cadence.title).to eq('group 1 Iterations')
expect(iteration_records.size).to eq(1)
expect(iteration_records.first.id).to eq(iteration_1.id)
iterations_cadence = iterations_cadences.find_by(group_id: group_3.id)
iteration_records = iterations.where(iterations_cadence_id: iterations_cadence.id)
expect(iterations_cadence.start_date).to eq(iteration_3.start_date)
expect(iterations_cadence.last_run_date).to eq(iteration_3.start_date)
expect(iterations_cadence.title).to eq('group 3 Iterations')
expect(iteration_records.size).to eq(2)
expect(iteration_records.first.id).to eq(iteration_2.id)
expect(iteration_records.second.id).to eq(iteration_3.id)
end
end
context 'when an iteration cadence exists for a group' do
let!(:group) { namespaces.create!(name: 'group', path: 'group') }
let!(:iterations_cadence_1) { iterations_cadences.create!(group_id: group.id, start_date: 5.days.ago, title: 'Cadence 1') }
let!(:iterations_cadence_2) { iterations_cadences.create!(group_id: group.id, start_date: 2.days.ago, title: 'Cadence 2') }
let!(:iteration_1) { iterations.create!(group_id: group.id, iid: 1, title: 'Iteration 1', start_date: 10.days.ago, due_date: 8.days.ago) }
let!(:iteration_2) { iterations.create!(group_id: group.id, iterations_cadence_id: iterations_cadence_1.id, iid: 2, title: 'Iteration 2', start_date: 5.days.ago, due_date: 3.days.ago) }
let!(:iteration_3) { iterations.create!(group_id: group.id, iterations_cadence_id: iterations_cadence_2.id, iid: 3, title: 'Iteration 3', start_date: 2.days.ago, due_date: 1.day.ago) }
subject { described_class.new.perform(group.id) }
it 'does not create a new iterations_cadence' do
expect { subject }.not_to change { iterations_cadences.count }
end
it 'assigns iteration cadences to iterations if needed' do
subject
expect(iteration_1.reload.iterations_cadence_id).to eq(iterations_cadence_1.id)
expect(iteration_2.reload.iterations_cadence_id).to eq(iterations_cadence_1.id)
expect(iteration_3.reload.iterations_cadence_id).to eq(iterations_cadence_2.id)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
require_migration!
RSpec.describe ScheduleSetDefaultIterationCadences do
let(:namespaces) { table(:namespaces) }
let(:iterations) { table(:sprints) }
let(:group_1) { namespaces.create!(name: 'test_1', path: 'test_1') }
let!(:group_2) { namespaces.create!(name: 'test_2', path: 'test_2') }
let(:group_3) { namespaces.create!(name: 'test_3', path: 'test_3') }
let(:group_4) { namespaces.create!(name: 'test_4', path: 'test_4') }
let(:group_5) { namespaces.create!(name: 'test_5', path: 'test_5') }
let(:group_6) { namespaces.create!(name: 'test_6', path: 'test_6') }
let(:group_7) { namespaces.create!(name: 'test_7', path: 'test_7') }
let(:group_8) { namespaces.create!(name: 'test_8', path: 'test_8') }
let!(:iteration_1) { iterations.create!(iid: 1, title: 'iteration 1', group_id: group_1.id) }
let!(:iteration_2) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_3.id) }
let!(:iteration_3) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_4.id) }
let!(:iteration_4) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_5.id) }
let!(:iteration_5) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_6.id) }
let!(:iteration_6) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_7.id) }
let!(:iteration_7) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_8.id) }
around do |example|
freeze_time { Sidekiq::Testing.fake! { example.run } }
end
it 'schedules the background jobs', :aggregate_failures do
stub_const("#{described_class.name}::BATCH_SIZE", 3)
migrate!
expect(BackgroundMigrationWorker.jobs.size).to be(3)
expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(2.minutes, group_1.id, group_3.id, group_4.id)
expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(4.minutes, group_5.id, group_6.id, group_7.id)
expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(6.minutes, group_8.id)
end
end
......@@ -5,6 +5,13 @@ require 'spec_helper'
RSpec.describe Iteration do
let_it_be(:project) { create(:project) }
let_it_be(:group) { create(:group) }
let(:set_cadence) { nil }
describe 'associations' do
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:group) }
it { is_expected.to belong_to(:iterations_cadence).inverse_of(:iterations) }
end
describe "#iid" do
it "is properly scoped on project and group" do
......@@ -32,6 +39,59 @@ RSpec.describe Iteration do
end
end
describe 'setting iteration cadence' do
let_it_be(:iterations_cadence) { create(:iterations_cadence, group: group, start_date: 10.days.ago) }
let(:iteration) { create(:iteration, group: group, iterations_cadence: set_cadence, start_date: 2.days.from_now) }
context 'when iterations_cadence is set correctly' do
let(:set_cadence) { iterations_cadence}
it 'does not change the iterations_cadence' do
expect(iteration.iterations_cadence).to eq(iterations_cadence)
end
end
context 'when iterations_cadence exists for the group' do
let(:set_cadence) { nil }
it 'sets the iterations_cadence to the existing record' do
expect(iteration.iterations_cadence).to eq(iterations_cadence)
end
end
context 'when iterations_cadence does not exists for the group' do
let_it_be(:group) { create(:group, name: 'Test group')}
let(:iteration) { build(:iteration, group: group, iterations_cadence: set_cadence) }
it 'creates a default iterations_cadence and uses it for the iteration' do
expect { iteration.save! }.to change { Iterations::Cadence.count }.by(1)
end
it 'sets the newly created iterations_cadence to the reecord' do
iteration.save!
expect(iteration.iterations_cadence).to eq(Iterations::Cadence.last)
end
it 'creates the iterations_cadence with the correct attributes' do
iteration.save!
cadence = Iterations::Cadence.last
expect(cadence.reload.start_date).to eq(iteration.start_date)
expect(cadence.title).to eq('Test group Iterations')
end
end
context 'when iteration is a project iteration' do
it 'does not set the iterations_cadence' do
iteration = create(:iteration, iterations_cadence: nil, project: project, skip_project_validation: true)
expect(iteration.reload.iterations_cadence).to be_nil
end
end
end
describe '.filter_by_state' do
let_it_be(:closed_iteration) { create(:iteration, :closed, :skip_future_date_validation, group: group, start_date: 8.days.ago, due_date: 2.days.ago) }
let_it_be(:started_iteration) { create(:iteration, :started, :skip_future_date_validation, group: group, start_date: 1.day.ago, due_date: 6.days.from_now) }
......@@ -307,6 +367,43 @@ RSpec.describe Iteration do
end
end
describe '#validate_group' do
let_it_be(:iterations_cadence) { create(:iterations_cadence, group: group) }
context 'when the iteration and iteration cadence groups are same' do
it 'is valid' do
iteration = build(:iteration, group: group, iterations_cadence: iterations_cadence)
expect(iteration).to be_valid
end
end
context 'when the iteration and iteration cadence groups are different' do
it 'is invalid' do
other_group = create(:group)
iteration = build(:iteration, group: other_group, iterations_cadence: iterations_cadence)
expect(iteration).not_to be_valid
end
end
context 'when the iteration belongs to a project and the iteration cadence is set' do
it 'is invalid' do
iteration = build(:iteration, project: project, iterations_cadence: iterations_cadence, skip_project_validation: true)
expect(iteration).to be_invalid
end
end
context 'when the iteration belongs to a project and the iteration cadence is not set' do
it 'is valid' do
iteration = build(:iteration, project: project, skip_project_validation: true)
expect(iteration).to be_valid
end
end
end
describe '.within_timeframe' do
let_it_be(:now) { Time.current }
let_it_be(:project) { create(:project, :empty_repo) }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Iterations::Cadence do
describe 'associations' do
subject { build(:iterations_cadence) }
it { is_expected.to belong_to(:group) }
it { is_expected.to have_many(:iterations).inverse_of(:iterations_cadence) }
end
describe 'validations' do
subject { build(:iterations_cadence) }
it { is_expected.to validate_presence_of(:title) }
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(:active) }
it { is_expected.to validate_presence_of(:automatic) }
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