Skip to content
Snippets Groups Projects
Commit 5a081e09 authored by Alexandru Croitor's avatar Alexandru Croitor
Browse files

Automate issues roll-over from closed iteration to new one

When an iteration is closed if its corresponding iteration cadence
has roll-over flag enabled we will automatically move open issues
to next iteration.
parent f7807a3c
No related branches found
No related tags found
No related merge requests found
Showing
with 457 additions and 31 deletions
Loading
Loading
@@ -23,6 +23,7 @@ class Issue < ApplicationRecord
include IssueAvailableFeatures
include Todoable
include FromUnion
include EachBatch
 
extend ::Gitlab::Utils::Override
 
Loading
Loading
Loading
Loading
@@ -27,6 +27,10 @@ class BasePolicy < DeclarativePolicy::Base
with_options scope: :user, score: 0
condition(:security_bot) { @user&.security_bot? }
 
desc "User is automation bot"
with_options scope: :user, score: 0
condition(:automation_bot) { @user&.automation_bot? }
desc "User email is unconfirmed or user account is locked"
with_options scope: :user, score: 0
condition(:inactive) { @user&.confirmation_required_on_sign_in? || @user&.access_locked? }
Loading
Loading
Loading
Loading
@@ -53,6 +53,10 @@ def security_bot?
false
end
 
def automation_bot?
false
end
def deactivated?
false
end
Loading
Loading
Loading
Loading
@@ -202,6 +202,8 @@
- 2
- - issue_rebalancing
- 1
- - iterations
- 1
- - jira_connect
- 1
- - jira_importer
Loading
Loading
Loading
Loading
@@ -22,6 +22,8 @@ class Predefined
prepended do
include AtomicInternalId
include Timebox
include EachBatch
include AfterCommitQueue
 
attr_accessor :skip_future_date_validation
attr_accessor :skip_project_validation
Loading
Loading
@@ -61,6 +63,7 @@ class Predefined
 
scope :start_date_passed, -> { where('start_date <= ?', Date.current).where('due_date >= ?', Date.current) }
scope :due_date_passed, -> { where('due_date < ?', Date.current) }
scope :with_cadence, -> { preload([iterations_cadence: :group]) }
 
state_machine :state_enum, initial: :upcoming do
event :start do
Loading
Loading
@@ -71,6 +74,12 @@ class Predefined
transition [:upcoming, :started] => :closed
end
 
after_transition any => [:closed] do |iteration|
iteration.run_after_commit do
Iterations::RollOverIssuesWorker.perform_async([iteration.id]) if iteration.iterations_cadence&.can_roll_over?
end
end
state :upcoming, value: Iteration::STATE_ENUM_MAP[:upcoming]
state :started, value: Iteration::STATE_ENUM_MAP[:started]
state :closed, value: Iteration::STATE_ENUM_MAP[:closed]
Loading
Loading
Loading
Loading
@@ -50,5 +50,9 @@ def self.search_title(query)
def can_be_automated?
active? && automatic? && duration_in_weeks.to_i > 0 && iterations_in_advance.to_i > 0
end
def can_roll_over?
active? && automatic? && roll_over?
end
end
end
Loading
Loading
@@ -215,6 +215,10 @@ module GroupPolicy
enable :admin_iteration_cadence
end
 
rule { (automation_bot | developer) & iterations_available }.policy do
enable :rollover_issues
end
rule { reporter & epics_available }.policy do
enable :create_epic
enable :admin_epic
Loading
Loading
# frozen_string_literal: true
module Iterations
class RollOverIssuesService
PermissionsError = Class.new(StandardError)
BATCH_SIZE = 100
def initialize(user, from_iteration, to_iteration)
@user = user
@from_iteration = from_iteration
@to_iteration = to_iteration
end
def execute
return ::ServiceResponse.error(message: _('Operation not allowed'), http_status: 403) unless can_roll_over_issues?
from_iteration.issues.opened.each_batch(of: BATCH_SIZE) do |issues|
add_iteration_events, remove_iteration_events = iteration_events(issues, to_iteration.id)
ActiveRecord::Base.transaction do
issues.update_all(sprint_id: to_iteration.id)
Gitlab::Database.bulk_insert(ResourceIterationEvent.table_name, add_iteration_events) # rubocop:disable Gitlab/BulkInsert
Gitlab::Database.bulk_insert(ResourceIterationEvent.table_name, remove_iteration_events) # rubocop:disable Gitlab/BulkInsert
end
end
::ServiceResponse.success
end
private
attr_reader :user, :from_iteration, :to_iteration
def iteration_events(issues, new_iteration_id)
add_iteration_events = []
remove_iteration_events = []
issues.map do |issue|
add_iteration_events << common_event_attributes(issue).merge({ iteration_id: new_iteration_id, action: ResourceTimeboxEvent.actions[:add] })
remove_iteration_events << common_event_attributes(issue).merge({ iteration_id: issue.sprint_id, action: ResourceTimeboxEvent.actions[:remove] })
end
[add_iteration_events, remove_iteration_events]
end
def common_event_attributes(issue)
{
created_at: Time.current,
user_id: user.id,
issue_id: issue.id
}
end
def can_roll_over_issues?
user && to_iteration && from_iteration &&
!to_iteration.closed? && to_iteration.due_date > Date.current &&
(user.automation_bot? || user.can?(:rollover_issues, to_iteration))
end
end
end
Loading
Loading
@@ -764,6 +764,15 @@
:tags:
- :exclude_from_kubernetes
- :exclude_from_gitlab_com
- :name: iterations:iterations_roll_over_issues
:worker_name: Iterations::RollOverIssuesWorker
:feature_category: :issue_tracking
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
:idempotent: true
:tags: []
- :name: personal_access_tokens:personal_access_tokens_groups_policy
:worker_name: PersonalAccessTokens::Groups::PolicyWorker
:feature_category: :authentication_and_authorization
Loading
Loading
# frozen_string_literal: true
module Iterations
class RollOverIssuesWorker
include ApplicationWorker
BATCH_SIZE = 1000
idempotent!
queue_namespace :iterations
feature_category :issue_tracking
def perform(iteration_ids)
Iteration.closed.id_in(iteration_ids).each_batch(of: BATCH_SIZE) do |iterations_batch|
iterations_batch.with_cadence.each do |iteration|
cadence = iteration.iterations_cadence
next unless cadence.group.iteration_cadences_feature_flag_enabled? # keep this behind FF for now
next unless cadence.can_roll_over?
new_iteration = cadence.next_open_iteration(iteration.due_date)
# proactively generate some iterations in advance if no upcoming iteration found
# this should help prevent the case where issues roll-over is triggered but
# cadence did not yet generate the iterations in advance
unless new_iteration
response = Iterations::Cadences::CreateIterationsInAdvanceService.new(automation_bot, cadence).execute
if response.error?
log_error(cadence, iteration, nil, response)
next
end
end
response = Iterations::RollOverIssuesService.new(automation_bot, iteration, new_iteration).execute
log_error(cadence, iteration, new_iteration, response) if response.error?
end
end
end
private
def log_error(cadence, from_iteration, to_iteration, response)
logger.error(
worker: self.class.name,
cadence_id: cadence&.id,
from_iteration_id: from_iteration&.id,
to_iteration_id: to_iteration&.id,
group_id: cadence&.group&.id,
message: response.message
)
end
def automation_bot
@automation_bot_id ||= User.automation_bot
end
end
end
Loading
Loading
@@ -4,6 +4,7 @@ class IterationsUpdateStatusWorker
include ApplicationWorker
 
sidekiq_options retry: 3
BATCH_SIZE = 1000
 
idempotent!
 
Loading
Loading
@@ -18,16 +19,17 @@ def perform
private
 
def set_started_iterations
Iteration
.upcoming
.start_date_passed
.update_all(state_enum: ::Iteration::STATE_ENUM_MAP[:started])
Iteration.upcoming.start_date_passed.each_batch(of: BATCH_SIZE) do |iterations|
iterations.update_all(state_enum: ::Iteration::STATE_ENUM_MAP[:started], updated_at: Time.current)
end
end
 
def set_closed_iterations
Iteration
.upcoming.or(Iteration.started)
.due_date_passed
.update_all(state_enum: ::Iteration::STATE_ENUM_MAP[:closed])
Iteration.upcoming.or(Iteration.started).due_date_passed.each_batch(of: BATCH_SIZE) do |iterations|
closed_iteration_ids = iterations.pluck_primary_key
iterations.update_all(state_enum: ::Iteration::STATE_ENUM_MAP[:closed], updated_at: Time.current)
Iterations::RollOverIssuesWorker.perform_async(closed_iteration_ids)
end
end
end
Loading
Loading
@@ -461,4 +461,32 @@
let(:open_on_left) { min_date - 100.days }
let(:open_on_right) { max_date + 100.days }
end
context 'when closing iteration' do
let_it_be_with_reload(:iteration) { create(:iteration, group: group, start_date: 4.days.from_now, due_date: 1.week.from_now) }
context 'when cadence roll-over flag enabled' do
before do
iteration.iterations_cadence.update!(automatic: true, active: true, roll_over: true)
end
it 'triggers roll-over issues worker' do
expect(Iterations::RollOverIssuesWorker).to receive(:perform_async).with([iteration.id])
iteration.close!
end
end
context 'when cadence roll-over flag disabled' do
before do
iteration.iterations_cadence.update!(automatic: true, active: true, roll_over: false)
end
it 'triggers roll-over issues worker' do
expect(Iterations::RollOverIssuesWorker).not_to receive(:perform_async)
iteration.close!
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Iterations::RollOverIssuesService do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:closed_iteration1) { create(:closed_iteration, group: group) }
let_it_be(:closed_iteration2) { create(:closed_iteration, group: group) }
let_it_be(:started_iteration) { create(:started_iteration, group: group) }
let_it_be(:upcoming_iteration) { create(:upcoming_iteration, group: group) }
let_it_be(:open_issues) { create_list(:issue, 5, :opened, iteration: closed_iteration1)}
let_it_be(:closed_issues) { create_list(:issue, 5, :closed, iteration: closed_iteration1)}
let(:from_iteration) { closed_iteration1 }
let(:to_iteration) { started_iteration }
subject { described_class.new(user, from_iteration, to_iteration).execute }
context 'when from iteration or null iteration or both are nil' do
context 'when to iteration is nil' do
let(:to_iteration) { nil }
it 'returns error' do
expect(subject).to be_error
end
end
context 'when from iteration is nil' do
let(:from_iteration) { nil }
it 'returns error' do
expect(subject).to be_error
end
end
context 'when both from_iteration and to_iteration are nil' do
let(:from_iteration) { nil }
let(:to_iteration) { nil }
it 'returns error' do
expect(subject).to be_error
end
end
end
context 'when iterations are present' do
context 'when issues are rolled-over to a closed iteration' do
let(:to_iteration) { closed_iteration2 }
it 'returns error' do
expect(subject).to be_error
end
end
context 'when user does not have permission to roll-over issues' do
context 'when user is not a team member' do
it 'returns error' do
expect(subject).to be_error
end
end
context 'when user is a bot other than automation bot' do
let(:user) { User.security_bot }
it 'returns error' do
expect(subject).to be_error
end
end
context 'when user is a team member' do
before do
group.add_reporter(user)
end
it 'returns error' do
expect(subject).to be_error
end
end
end
context 'when user has permissions to roll-over issues' do
context 'when user is a team member' do
before do
group.add_developer(user)
end
it 'does not raise an exception' do
expect(from_iteration).to receive(:issues).and_call_original
expect(subject).not_to be_error
end
end
context 'when user is the automation bot' do
let(:user) { User.automation_bot }
it 'does not raise an exception' do
expect(from_iteration).to receive(:issues).and_call_original
expect(subject).not_to be_error
end
it 'rolls-over issues to next iteration' do
expect { subject }.to change { started_iteration.reload.issues }.from([]).to(open_issues)
.and(change { closed_iteration1.reload.issues }.from(open_issues + closed_issues).to(closed_issues) )
.and(change(ResourceIterationEvent, :count).by(10))
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Iterations::RollOverIssuesWorker do
let_it_be(:group1) { create(:group) }
let_it_be(:group2) { create(:group) }
let_it_be(:cadence1) { create(:iterations_cadence, group: group1, roll_over: true, automatic: true) }
let_it_be(:cadence2) { create(:iterations_cadence, group: group2, roll_over: true, automatic: true) }
let_it_be(:closed_iteration1) { create(:closed_iteration, iterations_cadence: cadence1, group: group1, start_date: 2.weeks.ago, due_date: 1.week.ago) }
let_it_be(:closed_iteration2) { create(:closed_iteration, iterations_cadence: cadence2, group: group2, start_date: 2.weeks.ago, due_date: 1.week.ago) }
let_it_be(:started_iteration1) { create(:started_iteration, iterations_cadence: cadence1, group: group1, start_date: 2.days.ago, due_date: 5.days.from_now) }
let_it_be(:started_iteration2) { create(:upcoming_iteration, iterations_cadence: cadence2, group: group2, start_date: 2.days.ago, due_date: 5.days.from_now) }
let(:mock_success_service) { double('mock service', execute: ::ServiceResponse.success) }
let(:mock_failure_service) { double('mock service', execute: ::ServiceResponse.error(message: 'some error')) }
subject(:worker) { described_class.new }
describe '#perform' do
context 'when iteration cadence is not automatic' do
before do
cadence1.update!(automatic: false)
end
it 'exits early' do
expect(Iterations::RollOverIssuesService).not_to receive(:new)
worker.perform(group1.iterations)
end
end
context 'when roll-over option on iteration cadence is not enabled' do
before do
cadence1.update!(roll_over: false)
end
it 'exits early' do
expect(Iterations::RollOverIssuesService).not_to receive(:new)
worker.perform(group1.iterations)
end
end
context 'when roll-over option on iteration cadence is enabled' do
context 'when service fails to create future iteration' do
it 'logs error' do
expect(Iterations::RollOverIssuesService).to receive(:new).and_return(mock_failure_service).once
expect(worker).to receive(:log_error)
worker.perform(group1.iterations)
end
end
context 'when cadence has upcoming iteration' do
it 'filters out any iterations that are not closed' do
expect(Iterations::RollOverIssuesService).to receive(:new).and_return(mock_success_service).once
expect(Iterations::Cadences::CreateIterationsInAdvanceService).not_to receive(:new)
expect(Iteration).to receive(:closed).and_call_original.once
worker.perform(group1.iterations)
end
end
context 'when cadence does not have upcoming iteration' do
let_it_be(:group) { create(:group) }
let_it_be(:cadence) { create(:iterations_cadence, group: group, roll_over: true, automatic: true) }
let_it_be(:closed_iteration1) { create(:closed_iteration, iterations_cadence: cadence, group: group, start_date: 2.weeks.ago, due_date: 1.week.ago) }
it 'creates a new iteration to roll-over issues' do
expect(Iterations::RollOverIssuesService).to receive(:new).and_return(mock_success_service).once
expect(Iterations::Cadences::CreateIterationsInAdvanceService).to receive(:new).and_return(mock_success_service)
expect(Iteration).to receive(:closed).and_call_original.once
worker.perform(cadence.iterations)
end
context 'when service fails to create future iteration' do
it 'logs error and exits early' do
expect(Iterations::RollOverIssuesService).not_to receive(:new)
expect(Iterations::Cadences::CreateIterationsInAdvanceService).to receive(:new).and_return(mock_failure_service)
expect(worker).to receive(:log_error)
worker.perform(cadence.iterations)
end
end
end
it 'avoids N+1 database queries' do
# warm-up
User.automation_bot # this create the automation bot user record
worker.send(:automation_bot) # this will trigger the check and initiate the @automation_bot instance var
representative = group1.iterations.closed.first
control_count = ActiveRecord::QueryRecorder.new { worker.perform(representative) }.count
# for each iteration 2 extra queries are needed:
# - find the next open iteration
# - select the open issues to be moved
# so we have 2 extra closed iterations compared to control count so we need 4 more queries
iteration_ids = [group1.iterations.closed.pluck(:id) + group2.iterations.closed.pluck(:id)].flatten
expect { worker.perform(iteration_ids) }.not_to exceed_query_limit(control_count + 4)
end
context 'with batches' do
before do
stub_const("#{described_class}::BATCH_SIZE", 1)
end
it "run in batches" do
expect(Iterations::RollOverIssuesService).to receive(:new).and_return(mock_success_service).twice
expect(Iteration).to receive(:closed).and_call_original.exactly(3).times
iteration_ids = [group1.iterations.closed.pluck(:id) + group2.iterations.closed.pluck(:id)].flatten
worker.perform(iteration_ids)
end
end
end
end
include_examples 'an idempotent worker' do
let(:job_args) { [group1.iterations] }
end
end
Loading
Loading
@@ -3,44 +3,50 @@
require 'spec_helper'
 
RSpec.describe IterationsUpdateStatusWorker do
let_it_be(:closed_iteration1) { create(:iteration, :skip_future_date_validation, start_date: 20.days.ago, due_date: 11.days.ago) }
let_it_be(:started_iteration1) { create(:iteration, :skip_future_date_validation, start_date: 10.days.ago, due_date: 3.days.ago) }
let_it_be(:started_iteration2) { create(:iteration, :skip_future_date_validation, start_date: 2.days.ago, due_date: 5.days.from_now) }
let_it_be(:upcoming_iteration) { create(:iteration, start_date: 11.days.from_now, due_date: 13.days.from_now) }
subject(:worker) { described_class.new }
 
describe '#perform' do
context 'when iterations are in `upcoming` state' do
let_it_be(:closed_iteration1) { create(:iteration, :skip_future_date_validation, start_date: 20.days.ago, due_date: 11.days.ago) }
let_it_be(:closed_iteration2) { create(:iteration, :skip_future_date_validation, start_date: 10.days.ago, due_date: 3.days.ago) }
let_it_be(:started_iteration) { create(:iteration, :skip_future_date_validation, start_date: Date.current, due_date: 10.days.from_now) }
let_it_be(:upcoming_iteration) { create(:iteration, start_date: 11.days.from_now, due_date: 13.days.from_now) }
before do
started_iteration1.update!(state: 'started')
closed_iteration1.update!(state: 'upcoming')
end
it 'schedules an issues roll-over job' do
expect(Iterations::RollOverIssuesWorker).to receive(:perform_async)
worker.perform
end
 
context 'when iterations with passed due dates are in `upcoming`, `started` or `closes` states' do
it 'updates the status of iterations that require it', :aggregate_failures do
expect(closed_iteration1.state).to eq('closed')
expect(closed_iteration2.state).to eq('closed')
expect(started_iteration.state).to eq('upcoming')
expect(closed_iteration1.state).to eq('upcoming')
expect(started_iteration1.state).to eq('started')
expect(started_iteration2.state).to eq('upcoming')
expect(upcoming_iteration.state).to eq('upcoming')
 
closed_iteration2.update!(state: 'upcoming')
worker.perform
 
expect(closed_iteration1.reload.state).to eq('closed')
expect(closed_iteration2.reload.state).to eq('closed')
expect(started_iteration.reload.state).to eq('started')
expect(started_iteration1.reload.state).to eq('closed')
expect(started_iteration2.reload.state).to eq('started')
expect(upcoming_iteration.reload.state).to eq('upcoming')
end
end
 
context 'when iterations are in `started` state' do
let_it_be(:iteration1) { create(:iteration, :skip_future_date_validation, start_date: 10.days.ago, due_date: 2.days.ago) }
let_it_be(:iteration2) { create(:iteration, :skip_future_date_validation, start_date: 1.day.ago, due_date: Date.today, state_enum: ::Iteration::STATE_ENUM_MAP[:started]) }
context 'in batches' do
before do
stub_const("#{described_class}::BATCH_SIZE", 1)
end
 
it 'updates from started to closed when due date is past, does not touch others', :aggregate_failures do
expect(iteration1.state).to eq('closed')
expect(iteration2.state).to eq('started')
iteration1.update!(state: 'started')
worker.perform
it "run in batches" do
expect(Iterations::RollOverIssuesWorker).to receive(:perform_async).twice
 
expect(iteration1.reload.state).to eq('closed')
expect(iteration2.reload.state).to eq('started')
worker.perform
end
end
end
end
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment