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 ...@@ -12,10 +12,11 @@ module HasUserType
ghost: 5, ghost: 5,
project_bot: 6, project_bot: 6,
migration_bot: 7, migration_bot: 7,
security_bot: 8 security_bot: 8,
automation_bot: 9
}.with_indifferent_access.freeze }.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 NON_INTERNAL_USER_TYPES = %w[human project_bot service_user].freeze
INTERNAL_USER_TYPES = (USER_TYPES.keys - NON_INTERNAL_USER_TYPES).freeze INTERNAL_USER_TYPES = (USER_TYPES.keys - NON_INTERNAL_USER_TYPES).freeze
......
...@@ -44,7 +44,6 @@ module Timebox ...@@ -44,7 +44,6 @@ module Timebox
validates :project, presence: true, unless: :group validates :project, presence: true, unless: :group
validates :title, presence: true validates :title, presence: true
validate :uniqueness_of_title, if: :title_changed?
validate :timebox_type_check 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 :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? }
validate :dates_within_4_digits validate :dates_within_4_digits
...@@ -243,18 +242,6 @@ module Timebox ...@@ -243,18 +242,6 @@ module Timebox
end end
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 # Timebox should be either a project timebox or a group timebox
def timebox_type_check def timebox_type_check
if group_id && project_id if group_id && project_id
......
...@@ -36,6 +36,7 @@ class Milestone < ApplicationRecord ...@@ -36,6 +36,7 @@ class Milestone < ApplicationRecord
scope :order_by_dates_and_title, -> { order(due_date: :asc, start_date: :asc, title: :asc) } 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(",") } 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 state_machine :state, initial: :active do
event :close do event :close do
...@@ -172,4 +173,16 @@ class Milestone < ApplicationRecord ...@@ -172,4 +173,16 @@ class Milestone < ApplicationRecord
def issues_finder_params def issues_finder_params
{ project_id: project_id, group_id: group_id, include_subgroups: group_id.present? }.compact { project_id: project_id, group_id: group_id, include_subgroups: group_id.present? }.compact
end 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 end
...@@ -782,6 +782,16 @@ class User < ApplicationRecord ...@@ -782,6 +782,16 @@ class User < ApplicationRecord
end end
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, # Return true if there is only single non-internal user in the deployment,
# ghost user is ignored. # ghost user is ignored.
def single_user? def single_user?
......
...@@ -687,6 +687,9 @@ Gitlab.ee do ...@@ -687,6 +687,9 @@ Gitlab.ee do
Settings.cron_jobs['iterations_update_status_worker'] ||= Settingslogic.new({}) 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']['cron'] ||= '5 0 * * *'
Settings.cron_jobs['iterations_update_status_worker']['job_class'] = 'IterationsUpdateStatusWorker' 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'] ||= Settingslogic.new({})
Settings.cron_jobs['vulnerability_statistics_schedule_worker']['cron'] ||= '15 1 * * *' Settings.cron_jobs['vulnerability_statistics_schedule_worker']['cron'] ||= '15 1 * * *'
Settings.cron_jobs['vulnerability_statistics_schedule_worker']['job_class'] = 'Vulnerabilities::Statistics::ScheduleWorker' 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 ...@@ -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 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 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); 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); ...@@ -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 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); CREATE UNIQUE INDEX index_sprints_on_project_id_and_iid ON sprints USING btree (project_id, iid);
...@@ -44,11 +44,14 @@ module EE ...@@ -44,11 +44,14 @@ module EE
validate :future_date, if: :start_or_due_dates_changed?, unless: :skip_future_date_validation validate :future_date, if: :start_or_due_dates_changed?, unless: :skip_future_date_validation
validate :no_project, unless: :skip_project_validation validate :no_project, unless: :skip_project_validation
validate :validate_group validate :validate_group
validate :uniqueness_of_title, if: :title_changed?
before_validation :set_iterations_cadence, unless: -> { project_id.present? } before_validation :set_iterations_cadence, unless: -> { project_id.present? }
before_save :set_iteration_state before_save :set_iteration_state
before_destroy :check_if_can_be_destroyed 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 :upcoming, -> { with_state(:upcoming) }
scope :started, -> { with_state(:started) } scope :started, -> { with_state(:started) }
scope :closed, -> { with_state(:closed) } scope :closed, -> { with_state(:closed) }
...@@ -140,6 +143,15 @@ module EE ...@@ -140,6 +143,15 @@ module EE
resource_parent&.feature_available?(:iterations) && weight_available? resource_parent&.feature_available?(:iterations) && weight_available?
end 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 private
def last_iteration_in_cadence? def last_iteration_in_cadence?
...@@ -251,5 +263,12 @@ module EE ...@@ -251,5 +263,12 @@ module EE
errors.add(:group, s_('is not valid. The iteration group has to match the iteration cadence group.')) errors.add(:group, s_('is not valid. The iteration group has to match the iteration cadence group.'))
end 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
end end
...@@ -3,29 +3,63 @@ ...@@ -3,29 +3,63 @@
module Iterations module Iterations
class Cadence < ApplicationRecord class Cadence < ApplicationRecord
include Gitlab::SQL::Pattern include Gitlab::SQL::Pattern
include EachBatch
self.table_name = 'iterations_cadences' self.table_name = 'iterations_cadences'
ITERATIONS_AUTOMATION_FIELDS = [:duration_in_weeks, :iterations_in_advance].freeze
belongs_to :group belongs_to :group
has_many :iterations, foreign_key: :iterations_cadence_id, inverse_of: :iterations_cadence has_many :iterations, foreign_key: :iterations_cadence_id, inverse_of: :iterations_cadence
validates :title, presence: true validates :title, presence: true
validates :start_date, presence: true validates :start_date, presence: true
validates :group_id, presence: true validates :group_id, presence: true
validates :duration_in_weeks, presence: true validates :duration_in_weeks, inclusion: { in: 0..4 }, allow_nil: true
validates :iterations_in_advance, presence: 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 :active, inclusion: [true, false]
validates :automatic, inclusion: [true, false] validates :automatic, inclusion: [true, false]
validates :description, length: { maximum: 5000 } 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_groups, -> (group_ids) { where(group_id: group_ids) }
scope :with_duration, -> (duration) { where(duration_in_weeks: duration) } scope :with_duration, -> (duration) { where(duration_in_weeks: duration) }
scope :is_automatic, -> (automatic) { where(automatic: automatic) } scope :is_automatic, -> (automatic) { where(automatic: automatic) }
scope :is_active, -> (active) { where(active: active) } scope :is_active, -> (active) { where(active: active) }
scope :ordered_by_title, -> { order(:title) } 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) def self.search_title(query)
fuzzy_search(query, [:title]) fuzzy_search(query, [:title])
end 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
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 @@ ...@@ -314,6 +314,24 @@
:idempotent: true :idempotent: true
:tags: :tags:
- :exclude_from_kubernetes - :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 - :name: cronjob:iterations_update_status
:worker_name: IterationsUpdateStatusWorker :worker_name: IterationsUpdateStatusWorker
:feature_category: :issue_tracking :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 ...@@ -7,8 +7,8 @@ FactoryBot.define do
factory :iterations_cadence, class: 'Iterations::Cadence' do factory :iterations_cadence, class: 'Iterations::Cadence' do
title title
duration_in_weeks { 0 } duration_in_weeks { 1 }
iterations_in_advance { 0 } iterations_in_advance { 1 }
group group
start_date { generate(:cadence_sequential_date) } start_date { generate(:cadence_sequential_date) }
end end
......
...@@ -547,6 +547,7 @@ RSpec.describe Iteration do ...@@ -547,6 +547,7 @@ RSpec.describe Iteration do
end end
it_behaves_like 'a timebox', :iteration do it_behaves_like 'a timebox', :iteration do
let(:cadence) { create(:iterations_cadence, group: group) }
let(:timebox_args) { [:skip_project_validation] } let(:timebox_args) { [:skip_project_validation] }
let(:timebox_table_name) { described_class.table_name.to_sym } let(:timebox_table_name) { described_class.table_name.to_sym }
...@@ -554,5 +555,29 @@ RSpec.describe Iteration do ...@@ -554,5 +555,29 @@ RSpec.describe Iteration do
let(:mid_point) { 1.year.from_now.to_date } let(:mid_point) { 1.year.from_now.to_date }
let(:open_on_left) { min_date - 100.days } let(:open_on_left) { min_date - 100.days }
let(:open_on_right) { max_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
end end
...@@ -637,7 +637,7 @@ RSpec.describe User do ...@@ -637,7 +637,7 @@ RSpec.describe User do
AND AND
("users"."user_type" IS NULL OR "users"."user_type" IN (6, 4)) ("users"."user_type" IS NULL OR "users"."user_type" IN (6, 4))
AND 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 SQL
expect(users.to_sql.squish).to eq expected_sql.squish expect(users.to_sql.squish).to eq expected_sql.squish
...@@ -665,7 +665,7 @@ RSpec.describe User do ...@@ -665,7 +665,7 @@ RSpec.describe User do
AND AND
("users"."user_type" IS NULL OR "users"."user_type" IN (6, 4)) ("users"."user_type" IS NULL OR "users"."user_type" IN (6, 4))
AND 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 AND
(EXISTS (SELECT 1 FROM "members" (EXISTS (SELECT 1 FROM "members"
WHERE "members"."user_id" = "users"."id" WHERE "members"."user_id" = "users"."id"
......
...@@ -90,7 +90,7 @@ RSpec.describe 'Creating an iteration cadence' do ...@@ -90,7 +90,7 @@ RSpec.describe 'Creating an iteration cadence' do
let(:attributes) { { title: '', duration_in_weeks: 1, active: false, automatic: false } } let(:attributes) { { title: '', duration_in_weeks: 1, active: false, automatic: false } }
it_behaves_like 'a mutation that returns errors in the response', 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 it 'does not create the iteration cadence' do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Iterations::Cadence, :count) 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 ...@@ -41,6 +41,38 @@ RSpec.describe Iterations::Cadences::CreateService do
expect(iteration_cadence.active).to eq(true) expect(iteration_cadence.active).to eq(true)
expect(iteration_cadence.automatic).to eq(true) expect(iteration_cadence.automatic).to eq(true)
end 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 end
context 'invalid params' do context 'invalid params' do
...@@ -50,13 +82,30 @@ RSpec.describe Iterations::Cadences::CreateService do ...@@ -50,13 +82,30 @@ RSpec.describe Iterations::Cadences::CreateService do
} }
end end
it 'does not create an iteration cadence but returns errors' do context 'when duration_in_weeks: nil and iterations_in_advance: nil' do
expect(response.error?).to be_truthy it 'does not create an iteration cadence but returns errors' do
expect(errors).to match([ expect(response.error?).to be_truthy
"Start date can't be blank", expect(errors).to match([
"Duration in weeks can't be blank", "Start date can't be blank",
"Iterations in advance 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
end end
...@@ -94,6 +143,26 @@ RSpec.describe Iterations::Cadences::CreateService do ...@@ -94,6 +143,26 @@ RSpec.describe Iterations::Cadences::CreateService do
expect { response }.to change { Iterations::Cadence.count }.by(1) expect { response }.to change { Iterations::Cadence.count }.by(1)
end end
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
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 "" ...@@ -5812,6 +5812,9 @@ msgstr ""
msgid "CVE|Why Request a CVE ID?" msgid "CVE|Why Request a CVE ID?"
msgstr "" msgstr ""
msgid "Cadence is not automated"
msgstr ""
msgid "Callback URL" msgid "Callback URL"
msgstr "" msgstr ""
...@@ -37934,6 +37937,9 @@ msgstr "" ...@@ -37934,6 +37937,9 @@ msgstr ""
msgid "already being used for another group or project %{timebox_name}." msgid "already being used for another group or project %{timebox_name}."
msgstr "" msgstr ""
msgid "already being used for another iteration within this cadence."
msgstr ""
msgid "already has a \"created\" issue link" msgid "already has a \"created\" issue link"
msgstr "" msgstr ""
......
...@@ -5,7 +5,7 @@ require 'spec_helper' ...@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe User do RSpec.describe User do
specify 'types consistency checks', :aggregate_failures do specify 'types consistency checks', :aggregate_failures do
expect(described_class::USER_TYPES.keys) 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::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::NON_INTERNAL_USER_TYPES)
expect(described_class::USER_TYPES).to include(*described_class::INTERNAL_USER_TYPES) expect(described_class::USER_TYPES).to include(*described_class::INTERNAL_USER_TYPES)
......
...@@ -8,7 +8,46 @@ RSpec.describe Milestone do ...@@ -8,7 +8,46 @@ RSpec.describe Milestone do
let(:milestone) { create(:milestone, project: project) } let(:milestone) { create(:milestone, project: project) }
let(:project) { create(:project, :public) } 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 describe 'MilestoneStruct#serializable_hash' do
let(:predefined_milestone) { described_class::TimeboxStruct.new('Test Milestone', '#test', 1) } let(:predefined_milestone) { described_class::TimeboxStruct.new('Test Milestone', '#test', 1) }
......
...@@ -5248,9 +5248,10 @@ RSpec.describe User do ...@@ -5248,9 +5248,10 @@ RSpec.describe User do
let_it_be(:user3) { create(:user, :ghost) } let_it_be(:user3) { create(:user, :ghost) }
let_it_be(:user4) { create(:user, user_type: :support_bot) } 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(: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 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
end end
...@@ -5385,7 +5386,8 @@ RSpec.describe User do ...@@ -5385,7 +5386,8 @@ RSpec.describe User do
{ user_type: :ghost }, { user_type: :ghost },
{ user_type: :alert_bot }, { user_type: :alert_bot },
{ user_type: :support_bot }, { user_type: :support_bot },
{ user_type: :security_bot } { user_type: :security_bot },
{ user_type: :automation_bot }
] ]
end end
...@@ -5441,6 +5443,7 @@ RSpec.describe User do ...@@ -5441,6 +5443,7 @@ RSpec.describe User do
'alert_bot' | false 'alert_bot' | false
'support_bot' | false 'support_bot' | false
'security_bot' | false 'security_bot' | false
'automation_bot' | false
end end
with_them do with_them do
...@@ -5588,10 +5591,12 @@ RSpec.describe User do ...@@ -5588,10 +5591,12 @@ RSpec.describe User do
it_behaves_like 'bot users', :migration_bot it_behaves_like 'bot users', :migration_bot
it_behaves_like 'bot users', :security_bot it_behaves_like 'bot users', :security_bot
it_behaves_like 'bot users', :ghost 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', :alert_bot, 'alert-bot.png'
it_behaves_like 'bot user avatars', :support_bot, 'support-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', :security_bot, 'security-bot.png'
it_behaves_like 'bot user avatars', :automation_bot, 'support-bot.png'
context 'when bot is the support_bot' do context 'when bot is the support_bot' do
subject { described_class.support_bot } subject { described_class.support_bot }
......
...@@ -86,45 +86,6 @@ RSpec.shared_examples 'a timebox' do |timebox_type| ...@@ -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.") expect(timebox.errors[:project_id]).to include("#{timebox_type} should belong either to a project or a group.")
end end
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 end
describe "Associations" do 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