Commit fa73640f authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Merge branch 'automate-create-iterations-in-cadence' into 'master'

Add auto-generation of iterations for iteration cadences

See merge request gitlab-org/gitlab!62348
parents 4756edbe 54033334
......@@ -12,10 +12,11 @@ module HasUserType
ghost: 5,
project_bot: 6,
migration_bot: 7,
security_bot: 8
security_bot: 8,
automation_bot: 9
}.with_indifferent_access.freeze
BOT_USER_TYPES = %w[alert_bot project_bot support_bot visual_review_bot migration_bot security_bot].freeze
BOT_USER_TYPES = %w[alert_bot project_bot support_bot visual_review_bot migration_bot security_bot automation_bot].freeze
NON_INTERNAL_USER_TYPES = %w[human project_bot service_user].freeze
INTERNAL_USER_TYPES = (USER_TYPES.keys - NON_INTERNAL_USER_TYPES).freeze
......
......@@ -44,7 +44,6 @@ module Timebox
validates :project, presence: true, unless: :group
validates :title, presence: true
validate :uniqueness_of_title, if: :title_changed?
validate :timebox_type_check
validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? }
validate :dates_within_4_digits
......@@ -243,18 +242,6 @@ module Timebox
end
end
# Timebox titles must be unique across project and group timeboxes
def uniqueness_of_title
if project
relation = self.class.for_projects_and_groups([project_id], [project.group&.id])
elsif group
relation = self.class.for_projects_and_groups(group.projects.select(:id), [group.id])
end
title_exists = relation.find_by_title(title)
errors.add(:title, _("already being used for another group or project %{timebox_name}.") % { timebox_name: timebox_name }) if title_exists
end
# Timebox should be either a project timebox or a group timebox
def timebox_type_check
if group_id && project_id
......
......@@ -36,6 +36,7 @@ class Milestone < ApplicationRecord
scope :order_by_dates_and_title, -> { order(due_date: :asc, start_date: :asc, title: :asc) }
validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") }
validate :uniqueness_of_title, if: :title_changed?
state_machine :state, initial: :active do
event :close do
......@@ -172,4 +173,16 @@ class Milestone < ApplicationRecord
def issues_finder_params
{ project_id: project_id, group_id: group_id, include_subgroups: group_id.present? }.compact
end
# milestone titles must be unique across project and group milestones
def uniqueness_of_title
if project
relation = self.class.for_projects_and_groups([project_id], [project.group&.id])
elsif group
relation = self.class.for_projects_and_groups(group.projects.select(:id), [group.id])
end
title_exists = relation.find_by_title(title)
errors.add(:title, _("already being used for another group or project %{timebox_name}.") % { timebox_name: timebox_name }) if title_exists
end
end
......@@ -782,6 +782,16 @@ class User < ApplicationRecord
end
end
def automation_bot
email_pattern = "automation%s@#{Settings.gitlab.host}"
unique_internal(where(user_type: :automation_bot), 'automation-bot', email_pattern) do |u|
u.bio = 'The GitLab automation bot used for automated workflows and tasks'
u.name = 'GitLab Automation Bot'
u.avatar = bot_avatar(image: 'support-bot.png') # todo: add an avatar for automation-bot
end
end
# Return true if there is only single non-internal user in the deployment,
# ghost user is ignored.
def single_user?
......
......@@ -687,6 +687,9 @@ Gitlab.ee do
Settings.cron_jobs['iterations_update_status_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['iterations_update_status_worker']['cron'] ||= '5 0 * * *'
Settings.cron_jobs['iterations_update_status_worker']['job_class'] = 'IterationsUpdateStatusWorker'
Settings.cron_jobs['iterations_generator_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['iterations_generator_worker']['cron'] ||= '5 0 * * *'
Settings.cron_jobs['iterations_generator_worker']['job_class'] = 'Iterations::Cadences::ScheduleCreateIterationsWorker'
Settings.cron_jobs['vulnerability_statistics_schedule_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['vulnerability_statistics_schedule_worker']['cron'] ||= '15 1 * * *'
Settings.cron_jobs['vulnerability_statistics_schedule_worker']['job_class'] = 'Vulnerabilities::Statistics::ScheduleWorker'
......
# frozen_string_literal: true
class AddIndexForCadenceIterationsAutomation < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
INDEX_NAME = 'cadence_create_iterations_automation'
disable_ddl_transaction!
def up
return if index_exists_by_name?(:iterations_cadences, INDEX_NAME)
execute(
<<-SQL
CREATE INDEX CONCURRENTLY #{INDEX_NAME} ON iterations_cadences
USING BTREE(automatic, duration_in_weeks, (DATE ((COALESCE("iterations_cadences"."last_run_date", DATE('01-01-1970')) + "iterations_cadences"."duration_in_weeks" * INTERVAL '1 week'))))
WHERE duration_in_weeks IS NOT NULL
SQL
)
end
def down
remove_concurrent_index_by_name :iterations_cadences, INDEX_NAME
end
end
# frozen_string_literal: true
class ChangeIterationsTitleUniquenessIndex < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
INDEX_NAME = 'index_sprints_on_iterations_cadence_id_and_title'
OLD_INDEX_NAME = 'index_sprints_on_group_id_and_title'
disable_ddl_transaction!
def up
add_concurrent_index :sprints, [:iterations_cadence_id, :title], name: INDEX_NAME, unique: true
remove_concurrent_index_by_name :sprints, OLD_INDEX_NAME
end
def down
# noop
# rollback would not work as we can have duplicate records once the unique `index_sprints_on_group_id_and_title` index is removed
end
end
983b736defaa128f7466a784d2a06de293fa6b1cee76121e533e7966d19aad73
\ No newline at end of file
8aa9e00be5f2bc6076f4a42a479aff4318b9e4d3da48798117fec67df7158db4
\ No newline at end of file
......@@ -22305,6 +22305,8 @@ CREATE INDEX approval_mr_rule_index_merge_request_id ON approval_merge_request_r
CREATE UNIQUE INDEX bulk_import_trackers_uniq_relation_by_entity ON bulk_import_trackers USING btree (bulk_import_entity_id, relation);
CREATE INDEX cadence_create_iterations_automation ON iterations_cadences USING btree (automatic, duration_in_weeks, date((COALESCE(last_run_date, '1970-01-01'::date) + ((duration_in_weeks)::double precision * '7 days'::interval)))) WHERE (duration_in_weeks IS NOT NULL);
CREATE INDEX ci_builds_gitlab_monitor_metrics ON ci_builds USING btree (status, created_at, project_id) WHERE ((type)::text = 'Ci::Build'::text);
CREATE INDEX code_owner_approval_required ON protected_branches USING btree (project_id, code_owner_approval_required) WHERE (code_owner_approval_required = true);
......@@ -24647,7 +24649,7 @@ CREATE INDEX index_sprints_on_due_date ON sprints USING btree (due_date);
CREATE INDEX index_sprints_on_group_id ON sprints USING btree (group_id);
CREATE UNIQUE INDEX index_sprints_on_group_id_and_title ON sprints USING btree (group_id, title) WHERE (group_id IS NOT NULL);
CREATE UNIQUE INDEX index_sprints_on_iterations_cadence_id_and_title ON sprints USING btree (iterations_cadence_id, title);
CREATE UNIQUE INDEX index_sprints_on_project_id_and_iid ON sprints USING btree (project_id, iid);
......@@ -44,11 +44,14 @@ module EE
validate :future_date, if: :start_or_due_dates_changed?, unless: :skip_future_date_validation
validate :no_project, unless: :skip_project_validation
validate :validate_group
validate :uniqueness_of_title, if: :title_changed?
before_validation :set_iterations_cadence, unless: -> { project_id.present? }
before_save :set_iteration_state
before_destroy :check_if_can_be_destroyed
scope :due_date_order_asc, -> { order(:due_date) }
scope :due_date_order_desc, -> { order(due_date: :desc) }
scope :upcoming, -> { with_state(:upcoming) }
scope :started, -> { with_state(:started) }
scope :closed, -> { with_state(:closed) }
......@@ -140,6 +143,15 @@ module EE
resource_parent&.feature_available?(:iterations) && weight_available?
end
# because iteration start and due date are dates and not datetime and
# we do not allow for dates of 2 iterations to overlap a week ends-up being 6 days.
# i.e. instead of having something like: 2020-01-01 00:00:00 - 2020-01-08 00:00:00
# we would convene to have 2020-01-01 00:00:00 - 2020-01-07 23:59:59 and because iteration dates have no time
# we end up having 2020-01-01(beginning of day) - 2020-01-07(end of day)
def duration_in_days
(due_date - start_date + 1).to_i
end
private
def last_iteration_in_cadence?
......@@ -251,5 +263,12 @@ module EE
errors.add(:group, s_('is not valid. The iteration group has to match the iteration cadence group.'))
end
def uniqueness_of_title
relation = self.class.where(iterations_cadence_id: self.iterations_cadence)
title_exists = relation.find_by_title(title)
errors.add(:title, _('already being used for another iteration within this cadence.')) if title_exists
end
end
end
......@@ -3,29 +3,63 @@
module Iterations
class Cadence < ApplicationRecord
include Gitlab::SQL::Pattern
include EachBatch
self.table_name = 'iterations_cadences'
ITERATIONS_AUTOMATION_FIELDS = [:duration_in_weeks, :iterations_in_advance].freeze
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 :duration_in_weeks, presence: true
validates :iterations_in_advance, presence: true
validates :duration_in_weeks, inclusion: { in: 0..4 }, allow_nil: true
validates :duration_in_weeks, presence: true, if: :automatic?
validates :iterations_in_advance, inclusion: { in: 0..10 }, allow_nil: true
validates :iterations_in_advance, presence: true, if: :automatic?
validates :active, inclusion: [true, false]
validates :automatic, inclusion: [true, false]
validates :description, length: { maximum: 5000 }
after_commit :ensure_iterations_in_advance, on: [:create, :update], if: :changed_iterations_automation_fields?
scope :with_groups, -> (group_ids) { where(group_id: group_ids) }
scope :with_duration, -> (duration) { where(duration_in_weeks: duration) }
scope :is_automatic, -> (automatic) { where(automatic: automatic) }
scope :is_active, -> (active) { where(active: active) }
scope :ordered_by_title, -> { order(:title) }
scope :for_automated_iterations, -> do
is_automatic(true)
.where('duration_in_weeks > 0')
.where("DATE ((COALESCE(iterations_cadences.last_run_date, DATE('01-01-1970')) + iterations_cadences.duration_in_weeks * INTERVAL '1 week')) <= CURRENT_DATE")
end
def self.search_title(query)
fuzzy_search(query, [:title])
end
def next_open_iteration(date)
return unless date
iterations.without_state_enum(:closed).where('start_date >= ?', date).order(start_date: :asc).first
end
def can_be_automated?
active? && automatic? && duration_in_weeks.to_i > 0 && iterations_in_advance.to_i > 0
end
def duration_in_days
duration_in_weeks * 7
end
def ensure_iterations_in_advance
::Iterations::Cadences::CreateIterationsWorker.perform_async(self.id) if self.can_be_automated?
end
def changed_iterations_automation_fields?
(previous_changes.keys.map(&:to_sym) & ITERATIONS_AUTOMATION_FIELDS).present?
end
end
end
# frozen_string_literal: true
module Iterations
module Cadences
class CreateIterationsInAdvanceService
include Gitlab::Utils::StrongMemoize
def initialize(user, cadence)
@user = user
@cadence = cadence
end
def execute
return ::ServiceResponse.error(message: _('Operation not allowed'), http_status: 403) unless can_create_iterations_in_cadence?
return ::ServiceResponse.error(message: _('Cadence is not automated'), http_status: 422) unless cadence.can_be_automated?
update_existing_iterations!
::Gitlab::Database.bulk_insert(Iteration.table_name, build_new_iterations) # rubocop:disable Gitlab/BulkInsert
cadence.update!(last_run_date: compute_last_run_date)
::ServiceResponse.success
end
private
attr_accessor :user, :cadence
def build_new_iterations
new_iterations = []
new_start_date = new_iteration_start_date
iteration_number = new_iteration_number
Iteration.with_group_iid_supply(cadence.group) do |supply|
1.upto(new_iterations_count) do
iteration = build_iteration(cadence, new_start_date, iteration_number, supply.next_value)
new_iterations << iteration
iteration_number += 1
new_start_date = iteration[:due_date] + 1.day
end
new_iterations
end
end
def build_iteration(cadence, next_start_date, iteration_number, iid)
current_time = Time.current
duration = cadence.duration_in_weeks
# because iteration start and due date are dates and not datetime and
# we do not allow for dates of 2 iterations to overlap a week ends-up being 6 days.
# i.e. instead of having something like: 2020-01-01 00:00:00 - 2020-01-08 00:00:00
# we would convene to have 2020-01-01 00:00:00 - 2020-01-07 23:59:59 and because iteration dates have no time
# we end up having 2020-01-01(beginning of day) - 2020-01-07(end of day)
start_date = next_start_date
due_date = start_date + duration.weeks - 1.day
title = "Iteration #{iteration_number}: #{start_date.strftime(Date::DATE_FORMATS[:long])} - #{due_date.strftime(Date::DATE_FORMATS[:long])}"
description = "Auto-generated iteration for cadence##{cadence.id}: #{cadence.title} for period between #{start_date.strftime(Date::DATE_FORMATS[:long])} - #{due_date.strftime(Date::DATE_FORMATS[:long])}."
{
iid: iid,
iterations_cadence_id: cadence.id,
created_at: current_time,
updated_at: current_time,
group_id: cadence.group_id,
start_date: start_date,
due_date: due_date,
title: title,
description: description
}
end
def start_date
@start_date ||= cadence.start_date >= Date.today ? cadence.start_date : Date.today
end
def existing_iterations_in_advance
# we will be allowing up to 10 iterations in advance, so it should be fine to load all in memory
@existing_iterations_in_advance ||= cadence_iterations.with_start_date_after(start_date).to_a
end
def cadence_iterations
cadence.iterations.due_date_order_asc
end
def last_cadence_iteration
@last_cadence_iteration ||= cadence_iterations.last
end
def new_iteration_number
@new_iteration_number ||= cadence_iterations.count + 1
end
def new_iteration_start_date
strong_memoize(:new_iteration_start_date) do
last_iteration_due_date = last_cadence_iteration&.due_date
last_iteration_due_date += 1.day if last_iteration_due_date
[last_iteration_due_date, cadence.start_date].compact.max
end
end
def new_iterations_count
strong_memoize(:new_iterations_count) do
if existing_iterations_in_advance.count == 0
if cadence.start_date >= Date.today
cadence.iterations_in_advance
else
backfill_iterations_count = ((Date.today - new_iteration_start_date - 1).to_f / (7 * cadence.duration_in_weeks).to_f).ceil
backfill_iterations_count + cadence.iterations_in_advance
end
else
cadence.iterations_in_advance - existing_iterations_in_advance.count
end
end
end
def update_existing_iterations!
return if existing_iterations_in_advance.empty?
prev_iteration = nil
duration_before = existing_iterations_in_advance.last.due_date - existing_iterations_in_advance.first.start_date
existing_iterations_in_advance.each do |iteration|
if iteration.duration_in_days != cadence.duration_in_days
iteration.start_date = prev_iteration.due_date + 1.day if prev_iteration
iteration.due_date = iteration.start_date + cadence.duration_in_days.days - 1.day
end
prev_iteration = iteration
end
duration_after = existing_iterations_in_advance.last.due_date - existing_iterations_in_advance.first.start_date
if duration_before > duration_after
existing_iterations_in_advance.each { |it| it.save! }
else
existing_iterations_in_advance.reverse_each { |it| it.save! }
end
end
def compute_last_run_date
reloaded_last_iteration = cadence_iterations.last
run_date = reloaded_last_iteration.due_date - ((cadence.iterations_in_advance - 1) * cadence.duration_in_weeks).weeks if reloaded_last_iteration
run_date ||= Date.today
run_date
end
def can_create_iterations_in_cadence?
cadence && user && cadence.group.iteration_cadences_feature_flag_enabled? &&
(user.automation_bot? || user.can?(:create_iteration_cadence, cadence))
end
end
end
end
......@@ -314,6 +314,24 @@
:idempotent: true
:tags:
- :exclude_from_kubernetes
- :name: cronjob:iterations_cadences_create_iterations
:worker_name: Iterations::Cadences::CreateIterationsWorker
:feature_category: :issue_tracking
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
:idempotent: true
:tags: []
- :name: cronjob:iterations_cadences_schedule_create_iterations
:worker_name: Iterations::Cadences::ScheduleCreateIterationsWorker
:feature_category: :issue_tracking
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
:idempotent: true
:tags: []
- :name: cronjob:iterations_update_status
:worker_name: IterationsUpdateStatusWorker
:feature_category: :issue_tracking
......
# frozen_string_literal: true
module Iterations
module Cadences
class CreateIterationsWorker
include ApplicationWorker
idempotent!
deduplicate :until_executed, including_scheduled: true
queue_namespace :cronjob
feature_category :issue_tracking
def perform(cadence_id)
cadence = ::Iterations::Cadence.find_by_id(cadence_id)
return unless cadence && cadence.group.iteration_cadences_feature_flag_enabled? # keep this behind FF for now
response = Iterations::Cadences::CreateIterationsInAdvanceService.new(automation_bot, cadence).execute
log_error(cadence, response) if response.error?
end
private
def log_error(cadence, response)
logger.error(
worker: self.class.name,
cadence_id: cadence&.id,
group_id: cadence&.group&.id,
message: response.message
)
end
def automation_bot
@automation_bot_id ||= User.automation_bot
end
end
end
end
# frozen_string_literal: true
module Iterations
module Cadences
class ScheduleCreateIterationsWorker
include ApplicationWorker
BATCH_SIZE = 1000
idempotent!
deduplicate :until_executed, including_scheduled: true
queue_namespace :cronjob
feature_category :issue_tracking
def perform
Iterations::Cadence.for_automated_iterations.each_batch(of: BATCH_SIZE) do |cadences|
cadences.each do |cadence|
Iterations::Cadences::CreateIterationsWorker.perform_async(cadence.id)
end
end
end
end
end
end
......@@ -7,8 +7,8 @@ FactoryBot.define do
factory :iterations_cadence, class: 'Iterations::Cadence' do
title
duration_in_weeks { 0 }
iterations_in_advance { 0 }
duration_in_weeks { 1 }
iterations_in_advance { 1 }
group
start_date { generate(:cadence_sequential_date) }
end
......
......@@ -547,6 +547,7 @@ RSpec.describe Iteration do
end
it_behaves_like 'a timebox', :iteration do
let(:cadence) { create(:iterations_cadence, group: group) }
let(:timebox_args) { [:skip_project_validation] }
let(:timebox_table_name) { described_class.table_name.to_sym }
......@@ -554,5 +555,29 @@ RSpec.describe Iteration do
let(:mid_point) { 1.year.from_now.to_date }
let(:open_on_left) { min_date - 100.days }
let(:open_on_right) { max_date + 100.days }
describe "#uniqueness_of_title" do
context "per group" do
let(:timebox) { create(:iteration, *timebox_args, iterations_cadence: cadence, group: group) }
before do
project.update!(group: group)
end
it "accepts the same title in the same group with different cadence" do
new_cadence = create(:iterations_cadence, group: group)
new_timebox = create(:iteration, iterations_cadence: new_cadence, group: group, title: timebox.title)
expect(new_timebox.iterations_cadence).not_to eq(timebox.iterations_cadence)
expect(new_timebox).to be_valid
end
it "does not accept the same title when in same cadence" do
new_timebox = described_class.new(group: group, iterations_cadence: cadence, title: timebox.title)
expect(new_timebox).not_to be_valid
end
end
end
end
end
......@@ -637,7 +637,7 @@ RSpec.describe User do
AND
("users"."user_type" IS NULL OR "users"."user_type" IN (6, 4))
AND
("users"."user_type" IS NULL OR "users"."user_type" NOT IN (2, 6, 1, 3, 7, 8))
("users"."user_type" IS NULL OR "users"."user_type" NOT IN (2, 6, 1, 3, 7, 8, 9))
SQL
expect(users.to_sql.squish).to eq expected_sql.squish
......@@ -665,7 +665,7 @@ RSpec.describe User do
AND
("users"."user_type" IS NULL OR "users"."user_type" IN (6, 4))
AND
("users"."user_type" IS NULL OR "users"."user_type" NOT IN (2, 6, 1, 3, 7, 8))
("users"."user_type" IS NULL OR "users"."user_type" NOT IN (2, 6, 1, 3, 7, 8, 9))
AND
(EXISTS (SELECT 1 FROM "members"
WHERE "members"."user_id" = "users"."id"
......
......@@ -90,7 +90,7 @@ RSpec.describe 'Creating an iteration cadence' 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"]
errors: ["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)
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Iterations::Cadences::CreateIterationsInAdvanceService do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:inactive_cadence) { create(:iterations_cadence, group: group, active: false, automatic: true, start_date: 2.weeks.ago) }
let_it_be(:manual_cadence) { create(:iterations_cadence, group: group, active: true, automatic: false, start_date: 2.weeks.ago) }
let_it_be_with_reload(:automated_cadence) { create(:iterations_cadence, group: group, active: true, automatic: true, start_date: 2.weeks.ago) }
subject { described_class.new(user, cadence).execute }
describe '#execute' do
context 'when user has permissions to create iterations' do
context 'when user is a group developer' do
before do
group.add_developer(user)
end
context 'with nil cadence' do
let(:cadence) { nil }
it 'returns error' do
expect(subject).to be_error
end
end
context 'with manual cadence' do
let(:cadence) { manual_cadence }
it 'returns error' do
expect(subject).to be_error
end
end
context 'with inactive cadence' do
let(:cadence) { inactive_cadence }
it 'returns error' do
expect(subject).to be_error
end
end
context 'with automatic and active cadence' do
let(:cadence) { automated_cadence }
it 'does not return error' do
expect(subject).not_to be_error
end
context 'when no iterations need to be created' do
let_it_be(:iteration) { create(:iteration, group: group, iterations_cadence: automated_cadence, start_date: 1.week.from_now, due_date: 2.weeks.from_now)}
it 'does not create any new iterations' do
expect { subject }.not_to change(Iteration, :count)
end
end
context 'when new iterations need to be created' do
context 'when no iterations exist' do
it 'creates new iterations' do
expect { subject }.to change(Iteration, :count).by(3)
end
end
context 'when cadence start date is in future' do
before do
automated_cadence.update!(iterations_in_advance: 3, start_date: 3.weeks.from_now)
end
it 'creates new iterations' do
expect { subject }.to change(Iteration, :count).by(3)
end
it 'sets last run date' do
expect(automated_cadence.iterations.count).to eq(0)
subject
expect(automated_cadence.reload.last_run_date).to eq(automated_cadence.iterations.last(3).first.due_date)
end
end
context 'when advanced iterations exist but cadence needs to create more' do
let_it_be(:current_iteration) { create(:iteration, group: group, iterations_cadence: automated_cadence, start_date: 3.days.ago, due_date: (1.week - 3.days).from_now)}
let_it_be(:next_iteration1) { create(:iteration, group: group, iterations_cadence: automated_cadence, start_date: current_iteration.due_date + 1.day, due_date: current_iteration.due_date + 1.week)}
let_it_be(:next_iteration2) { create(:iteration, group: group, iterations_cadence: automated_cadence, start_date: next_iteration1.due_date + 1.day, due_date: next_iteration1.due_date + 1.week)}
before do
automated_cadence.update!(iterations_in_advance: 3, duration_in_weeks: 3)
end
it 'creates new iterations' do
expect { subject }.to change(Iteration, :count).by(1)
expect(next_iteration1.reload.duration_in_days).to eq(21)
expect(next_iteration1.reload.start_date).to eq(current_iteration.due_date + 1.day)
expect(next_iteration1.reload.due_date).to eq(current_iteration.due_date + 3.weeks)
expect(next_iteration2.reload.duration_in_days).to eq(21)
expect(next_iteration2.reload.start_date).to eq(next_iteration1.due_date + 1.day)
expect(next_iteration2.reload.due_date).to eq(next_iteration1.due_date + 3.weeks)
end
end
context 'when advanced iterations exist but cadence changes duration to a smaller one' do
let_it_be(:current_iteration) { create(:iteration, group: group, iterations_cadence: automated_cadence, start_date: 3.days.ago, due_date: (1.week - 3.days).from_now)}
let_it_be(:next_iteration1) { create(:iteration, group: group, iterations_cadence: automated_cadence, start_date: current_iteration.due_date + 1.day, due_date: current_iteration.due_date + 3.weeks)}
let_it_be(:next_iteration2) { create(:iteration, group: group, iterations_cadence: automated_cadence, start_date: next_iteration1.due_date + 1.day, due_date: next_iteration1.due_date + 3.weeks)}
before do
automated_cadence.update!(iterations_in_advance: 3, duration_in_weeks: 1)
end
it 'creates new iterations' do
expect { subject }.to change(Iteration, :count).by(1)
expect(next_iteration1.reload.duration_in_days).to eq(7)
expect(next_iteration1.reload.start_date).to eq(current_iteration.due_date + 1.day)
expect(next_iteration1.reload.due_date).to eq(current_iteration.due_date + 1.week)
expect(next_iteration2.reload.duration_in_days).to eq(7)
expect(next_iteration2.reload.start_date).to eq(next_iteration1.due_date + 1.day)
expect(next_iteration2.reload.due_date).to eq(next_iteration1.due_date + 1.week)
end
end
context 'when cadence start date changes to a future date with existing iteration' do
let_it_be(:current_iteration) { create(:iteration, group: group, iterations_cadence: automated_cadence, start_date: 3.days.ago, due_date: (2.weeks - 3.days).from_now)}
before do
automated_cadence.update!(start_date: 3.days.from_now, iterations_in_advance: 2, duration_in_weeks: 2)
end
it 'creates new iterations' do
expect { subject }.to change(Iteration, :count).by(2)
end
end
context 'when cadence has iterations but all are in the past' do
let_it_be(:past_iteration1) { create(:iteration, group: group, title: 'Iteration 1', iterations_cadence: automated_cadence, start_date: 3.weeks.ago, due_date: 2.weeks.ago)}
let_it_be(:past_iteration2) { create(:iteration, group: group, title: 'Iteration 2', iterations_cadence: automated_cadence, start_date: past_iteration1.due_date + 1.day, due_date: past_iteration1.due_date + 1.week)}
before do
automated_cadence.update!(iterations_in_advance: 2)
end
it 'creates new iterations' do
# because last iteration ended 1 week ago, we generate one iteration for current week and 2 in advance
expect { subject }.to change(Iteration, :count).by(3)
end
it 'updates cadence last_run_date' do
# because cadence is set to generate 2 iterations in advance, we set last run date to due_date of the
# penultimate
subject
expect(automated_cadence.reload.last_run_date).to eq(automated_cadence.reload.iterations.last(2).first.due_date)
end
it 'sets the titles correctly based on iterations count and follow-up dates' do
subject
initial_start_date = past_iteration2.due_date + 1.day
initial_due_date = past_iteration2.due_date + 1.week
expect(group.reload.iterations.pluck(:title)).to eq([
'Iteration 1',
'Iteration 2',
"Iteration 3: #{(initial_start_date).strftime(Date::DATE_FORMATS[:long])} - #{initial_due_date.strftime(Date::DATE_FORMATS[:long])}",
"Iteration 4: #{(initial_due_date + 1.day).strftime(Date::DATE_FORMATS[:long])} - #{(initial_due_date + 1.week).strftime(Date::DATE_FORMATS[:long])}",
"Iteration 5: #{(initial_due_date + 1.week + 1.day).strftime(Date::DATE_FORMATS[:long])} - #{(initial_due_date + 2.weeks).strftime(Date::DATE_FORMATS[:long])}"
])
end
end
end
end
end
end
end
end
......@@ -41,6 +41,38 @@ RSpec.describe Iterations::Cadences::CreateService do
expect(iteration_cadence.active).to eq(true)
expect(iteration_cadence.automatic).to eq(true)
end
context 'create manual cadence' do
context 'when duration_in_weeks: nil and iterations_in_advance: nil' do
before do
params.merge!(automatic: false, duration_in_weeks: nil, iterations_in_advance: nil)
end
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 be_nil
expect(iteration_cadence.iterations_in_advance).to be_nil
expect(iteration_cadence.active).to eq(true)
expect(iteration_cadence.automatic).to eq(false)
end
end
context 'with out list of values for duration_in_weeks, iterations_in_advance' do
before do
params.merge!(automatic: false, duration_in_weeks: 15, iterations_in_advance: 15)
end
it 'does not create an iteration cadence but returns errors' do
expect(response.error?).to be_truthy
expect(errors).to match([
"Duration in weeks is not included in the list",
"Iterations in advance is not included in the list"
])
end
end
end
end
context 'invalid params' do
......@@ -50,13 +82,30 @@ RSpec.describe Iterations::Cadences::CreateService do
}
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"
])
context 'when duration_in_weeks: nil and iterations_in_advance: nil' do
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 'with out of list values for duration_in_weeks and iterations_in_advance' do
before do
params.merge!(duration_in_weeks: 15, iterations_in_advance: 15)
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 is not included in the list",
"Iterations in advance is not included in the list"
])
end
end
end
......@@ -94,6 +143,26 @@ RSpec.describe Iterations::Cadences::CreateService do
expect { response }.to change { Iterations::Cadence.count }.by(1)
end
end
context 'when create cadence can be automated' do
it 'invokes worker to create iterations in advance' do
params[:automatic] = true
expect(::Iterations::Cadences::CreateIterationsWorker).to receive(:perform_async)
response
end
end
context 'when create cadence is not automated' do
it 'invokes worker to create iterations in advance' do
params[:automatic] = false
expect(::Iterations::Cadences::CreateIterationsWorker).not_to receive(:perform_async)
response
end
end
end
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Iterations::Cadences::CreateIterationsWorker do
let_it_be(:group) { create(:group) }
let_it_be(:start_date) { 3.weeks.ago }
let_it_be(:cadence) { create(:iterations_cadence, group: group, automatic: true, start_date: start_date, duration_in_weeks: 1, iterations_in_advance: 2) }
let(:mock_service) { double('mock_service', execute: ::ServiceResponse.success) }
subject(:worker) { described_class.new }
describe '#perform' do
context 'when passing in nil cadence id' do
it 'exits early' do
expect(Iterations::Cadences::CreateIterationsInAdvanceService).not_to receive(:new)
worker.perform(nil)
end
end
context 'when passing in non-existent cadence id' do
it 'exits early' do
expect(Iterations::Cadences::CreateIterationsInAdvanceService).not_to receive(:new)
worker.perform(non_existing_record_id)
end
end
context 'when passing existent cadence id' do
let(:mock_success_service) { double('mock_service', execute: ::ServiceResponse.success) }
let(:mock_error_service) { double('mock_service', execute: ::ServiceResponse.error(message: 'some error')) }
it 'invokes CreateIterationsInAdvanceService' do
expect(Iterations::Cadences::CreateIterationsInAdvanceService).to receive(:new).with(kind_of(User), kind_of(Iterations::Cadence)).and_return(mock_success_service)
expect(worker).not_to receive(:log_error)
worker.perform(cadence.id)
end
context 'when CreateIterationsInAdvanceService returns error' do
it 'logs error' do
allow(Iterations::Cadences::CreateIterationsInAdvanceService).to receive(:new).and_return(mock_error_service)
allow(mock_service).to receive(:execute)
expect(worker).to receive(:log_error)
worker.perform(cadence.id)
end
end
end
end
include_examples 'an idempotent worker' do
let(:job_args) { [cadence.id] }
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Iterations::Cadences::ScheduleCreateIterationsWorker do
let_it_be(:group) { create(:group) }
let_it_be(:start_date) { 3.weeks.ago }
let_it_be(:iteration_cadences) { create_list(:iterations_cadence, 2, group: group, automatic: true, start_date: start_date, duration_in_weeks: 1, iterations_in_advance: 2) }
subject(:worker) { described_class.new }
describe '#perform' do
context 'in batches' do
before do
stub_const("#{described_class}::BATCH_SIZE", 1)
end
it 'run in batches' do
expect(Iterations::Cadences::CreateIterationsWorker).to receive(:perform_async).twice
expect(Iterations::Cadence).to receive(:for_automated_iterations).and_call_original.once
worker.perform
end
end
end
include_examples 'an idempotent worker'
end
......@@ -5812,6 +5812,9 @@ msgstr ""
msgid "CVE|Why Request a CVE ID?"
msgstr ""
msgid "Cadence is not automated"
msgstr ""
msgid "Callback URL"
msgstr ""
......@@ -37934,6 +37937,9 @@ msgstr ""
msgid "already being used for another group or project %{timebox_name}."
msgstr ""
msgid "already being used for another iteration within this cadence."
msgstr ""
msgid "already has a \"created\" issue link"
msgstr ""
......
......@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe User do
specify 'types consistency checks', :aggregate_failures do
expect(described_class::USER_TYPES.keys)
.to match_array(%w[human ghost alert_bot project_bot support_bot service_user security_bot visual_review_bot migration_bot])
.to match_array(%w[human ghost alert_bot project_bot support_bot service_user security_bot visual_review_bot migration_bot automation_bot])
expect(described_class::USER_TYPES).to include(*described_class::BOT_USER_TYPES)
expect(described_class::USER_TYPES).to include(*described_class::NON_INTERNAL_USER_TYPES)
expect(described_class::USER_TYPES).to include(*described_class::INTERNAL_USER_TYPES)
......
......@@ -8,7 +8,46 @@ RSpec.describe Milestone do
let(:milestone) { create(:milestone, project: project) }
let(:project) { create(:project, :public) }
it_behaves_like 'a timebox', :milestone
it_behaves_like 'a timebox', :milestone do
describe "#uniqueness_of_title" do
context "per project" do
it "does not accept the same title in a project twice" do
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 = timebox.dup
new_timebox.project = project
expect(new_timebox).to be_valid
end
end
context "per group" do
let(:timebox) { create(:milestone, *timebox_args, group: group) }
before do
project.update!(group: group)
end
it "does not accept the same title in a group twice" do
new_timebox = described_class.new(group: group, title: timebox.title)
expect(new_timebox).not_to be_valid
end
it "does not accept the same title of a child project timebox" do
create(:milestone, *timebox_args, project: group.projects.first)
new_timebox = described_class.new(group: group, title: timebox.title)
expect(new_timebox).not_to be_valid
end
end
end
end
describe 'MilestoneStruct#serializable_hash' do
let(:predefined_milestone) { described_class::TimeboxStruct.new('Test Milestone', '#test', 1) }
......
......@@ -5248,9 +5248,10 @@ RSpec.describe User do
let_it_be(:user3) { create(:user, :ghost) }
let_it_be(:user4) { create(:user, user_type: :support_bot) }
let_it_be(:user5) { create(:user, state: 'blocked', user_type: :support_bot) }
let_it_be(:user6) { create(:user, user_type: :automation_bot) }
it 'returns all active users including active bots but ghost users' do
expect(described_class.active_without_ghosts).to match_array([user1, user4])
expect(described_class.active_without_ghosts).to match_array([user1, user4, user6])
end
end
......@@ -5385,7 +5386,8 @@ RSpec.describe User do
{ user_type: :ghost },
{ user_type: :alert_bot },
{ user_type: :support_bot },
{ user_type: :security_bot }
{ user_type: :security_bot },
{ user_type: :automation_bot }
]
end
......@@ -5441,6 +5443,7 @@ RSpec.describe User do
'alert_bot' | false
'support_bot' | false
'security_bot' | false
'automation_bot' | false
end
with_them do
......@@ -5588,10 +5591,12 @@ RSpec.describe User do
it_behaves_like 'bot users', :migration_bot
it_behaves_like 'bot users', :security_bot
it_behaves_like 'bot users', :ghost
it_behaves_like 'bot users', :automation_bot
it_behaves_like 'bot user avatars', :alert_bot, 'alert-bot.png'
it_behaves_like 'bot user avatars', :support_bot, 'support-bot.png'
it_behaves_like 'bot user avatars', :security_bot, 'security-bot.png'
it_behaves_like 'bot user avatars', :automation_bot, 'support-bot.png'
context 'when bot is the support_bot' do
subject { described_class.support_bot }
......
......@@ -86,45 +86,6 @@ RSpec.shared_examples 'a timebox' do |timebox_type|
expect(timebox.errors[:project_id]).to include("#{timebox_type} should belong either to a project or a group.")
end
end
describe "#uniqueness_of_title" do
context "per project" do
it "does not accept the same title in a project twice" do
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 = timebox.dup
new_timebox.project = project
expect(new_timebox).to be_valid
end
end
context "per group" do
let(:timebox) { create(timebox_type, *timebox_args, group: group) }
before do
project.update!(group: group)
end
it "does not accept the same title in a group twice" do
new_timebox = described_class.new(group: group, title: timebox.title)
expect(new_timebox).not_to be_valid
end
it "does not accept the same title of a child project timebox" do
create(timebox_type, *timebox_args, project: group.projects.first)
new_timebox = described_class.new(group: group, title: timebox.title)
expect(new_timebox).not_to be_valid
end
end
end
end
describe "Associations" do
......
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