Commit 21f561fe authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Create burnup chart service for milestones

This computes the aggregated data per day so that we don't have to send
all the events and compute this in the frontend
parent d020969b
# frozen_string_literal: true
# This service computes the milestone's daily total number of issues and their weights.
# For each day, this returns the totals for all issues that are assigned to the milestone at that point in time.
# This represents the scope for this milestone. This also returns separate totals for closed issues which represent the completed work.
#
# This is implemented by iterating over all relevant resource events ordered by time. We need to do this
# so that we can keep track of the issue's state during that point in time and handle the events based on that.
class Milestones::BurnupChartService
def initialize(milestone)
raise ArgumentError, 'Milestone must have a start and due date' if milestone.start_date.blank? || milestone.due_date.blank?
@milestone = milestone
end
def execute
@issue_states = {}
@chart_data = []
resource_events.each do |event|
case event['event_type']
when 'milestone'
handle_milestone_event(event)
when 'state'
handle_state_event(event)
when 'weight'
handle_weight_event(event)
end
end
chart_data
end
private
attr_reader :milestone, :issue_states, :chart_data
def handle_milestone_event(event)
issue_state = find_issue_state(event['issue_id'])
return if issue_state[:milestone] == milestone.id && event['action'] == ResourceMilestoneEvent.actions[:add] && event['value'] == milestone.id
if event['action'] == ResourceMilestoneEvent.actions[:add] && event['value'] == milestone.id
handle_add_milestone_event(event)
elsif issue_state[:milestone] == milestone.id
# If the issue is currently assigned to the milestone, then treat any event here as a removal.
# We do not have a separate `:remove` event when replacing milestones with another one.
handle_remove_milestone_event(event)
end
issue_state[:milestone] = event['value']
end
def handle_add_milestone_event(event)
issue_state = find_issue_state(event['issue_id'])
increment_scope(event['created_at'], issue_state[:weight])
if issue_state[:state] == ResourceStateEvent.states[:closed]
increment_completed(event['created_at'], issue_state[:weight])
end
end
def handle_remove_milestone_event(event)
issue_state = find_issue_state(event['issue_id'])
decrement_scope(event['created_at'], issue_state[:weight])
if issue_state[:state] == ResourceStateEvent.states[:closed]
decrement_completed(event['created_at'], issue_state[:weight])
end
end
def handle_state_event(event)
issue_state = find_issue_state(event['issue_id'])
old_state = issue_state[:state]
issue_state[:state] = event['value']
return if issue_state[:milestone] != milestone.id
if old_state == ResourceStateEvent.states[:closed] && event['value'] == ResourceStateEvent.states[:reopened]
decrement_completed(event['created_at'], issue_state[:weight])
elsif ResourceStateEvent.states.values_at(:opened, :reopened).include?(old_state) && event['value'] == ResourceStateEvent.states[:closed]
increment_completed(event['created_at'], issue_state[:weight])
end
end
def handle_weight_event(event)
issue_state = find_issue_state(event['issue_id'])
old_weight = issue_state[:weight]
issue_state[:weight] = event['value'] || 0
return if issue_state[:milestone] != milestone.id
add_chart_data(event['created_at'], :scope_weight, issue_state[:weight] - old_weight)
if issue_state[:state] == ResourceStateEvent.states[:closed]
add_chart_data(event['created_at'], :completed_weight, issue_state[:weight] - old_weight)
end
end
def increment_scope(timestamp, weight)
add_chart_data(timestamp, :scope_count, 1)
add_chart_data(timestamp, :scope_weight, weight)
end
def decrement_scope(timestamp, weight)
add_chart_data(timestamp, :scope_count, -1)
add_chart_data(timestamp, :scope_weight, -weight)
end
def increment_completed(timestamp, weight)
add_chart_data(timestamp, :completed_count, 1)
add_chart_data(timestamp, :completed_weight, weight)
end
def decrement_completed(timestamp, weight)
add_chart_data(timestamp, :completed_count, -1)
add_chart_data(timestamp, :completed_weight, -weight)
end
def add_chart_data(timestamp, field, value)
date = timestamp.to_date
date = milestone.start_date if date < milestone.start_date
if chart_data.empty?
chart_data.push({
date: date,
scope_count: 0,
scope_weight: 0,
completed_count: 0,
completed_weight: 0
})
elsif chart_data.last[:date] != date
# To start a new day entry we copy the previous day's data because the numbers are cumulative
chart_data.push(
chart_data.last.merge(date: date)
)
end
chart_data.last[field] += value
end
def find_issue_state(issue_id)
issue_states[issue_id] ||= {
milestone: nil,
weight: 0,
state: ResourceStateEvent.states[:opened]
}
end
# rubocop: disable CodeReuse/ActiveRecord
def resource_events
union = Gitlab::SQL::Union.new([milestone_events, state_events, weight_events]) # rubocop: disable Gitlab/Union
ActiveRecord::Base.connection.execute("(#{union.to_sql}) ORDER BY created_at")
end
# rubocop: enable CodeReuse/ActiveRecord
def milestone_events
ResourceMilestoneEvent.by_issue_ids_and_created_at_earlier_or_equal_to(issue_ids, end_time)
.select('\'milestone\' AS event_type, created_at, milestone_id AS value, action, issue_id')
end
def state_events
ResourceStateEvent.by_issue_ids_and_created_at_earlier_or_equal_to(issue_ids, end_time)
.select('\'state\' AS event_type, created_at, state AS value, NULL AS action, issue_id')
end
def weight_events
ResourceWeightEvent.by_issue_ids_and_created_at_earlier_or_equal_to(issue_ids, end_time)
.select('\'weight\' AS event_type, created_at, weight AS value, NULL AS action, issue_id')
end
# rubocop: disable CodeReuse/ActiveRecord
def issue_ids
# We find all issues that have this milestone added before this milestone's due date.
# We cannot just filter by `issues.milestone_id` because there might be issues that have
# since been moved to other milestones and we still need to include these in this graph.
ResourceMilestoneEvent
.select(:issue_id)
.where({
milestone_id: milestone.id,
action: :add
})
.where('created_at <= ?', end_time)
end
# rubocop: enable CodeReuse/ActiveRecord
def end_time
@end_time ||= milestone.due_date.end_of_day
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Milestones::BurnupChartService do
let_it_be(:project) { create(:project) }
let_it_be(:milestone) { create(:milestone, project: project, start_date: '2020-01-01', due_date: '2020-01-15') }
let_it_be(:issues) { create_list(:issue, 5, project: project) }
let(:chart_data) { described_class.new(milestone).execute }
it 'raises an error when milestone does not have a start and due date' do
milestone = build(:milestone, project: project)
expect { described_class.new(milestone) }.to raise_error('Milestone must have a start and due date')
end
it 'aggregates events before the start date to the start date' do
create(:resource_milestone_event, issue: issues[0], milestone: milestone, action: :add, created_at: '2019-12-15')
create(:resource_weight_event, issue: issues[0], weight: 2, created_at: '2019-12-18')
create(:resource_milestone_event, issue: issues[1], milestone: milestone, action: :add, created_at: '2019-12-16')
create(:resource_weight_event, issue: issues[1], weight: 1, created_at: '2019-12-18')
create(:resource_milestone_event, issue: issues[2], milestone: milestone, action: :add, created_at: '2019-12-16')
create(:resource_weight_event, issue: issues[2], weight: 3, created_at: '2019-12-18')
create(:resource_state_event, issue: issues[2], state: :closed, created_at: '2019-12-25')
create(:resource_milestone_event, issue: issues[3], milestone: milestone, action: :add, created_at: '2019-12-17')
create(:resource_weight_event, issue: issues[3], weight: 4, created_at: '2019-12-18')
create(:resource_state_event, issue: issues[3], state: :closed, created_at: '2019-12-26')
expect(chart_data).to eq([
{
date: Date.parse('2020-01-01'),
scope_count: 4,
scope_weight: 10,
completed_count: 2,
completed_weight: 7
}
])
end
it 'updates counts and weight when the milestone is added or removed' do
# Add milestone to an open issue with no weight.
create(:resource_milestone_event, issue: issues[0], milestone: milestone, action: :add, created_at: '2020-01-05 03:00')
# Ignore duplicate add event.
create(:resource_milestone_event, issue: issues[0], milestone: milestone, action: :add, created_at: '2020-01-05 03:00')
# Add milestone to an open issue with weight 2 on the same day. This should increment the scope totals for the same day.
create(:resource_weight_event, issue: issues[1], weight: 2, created_at: '2020-01-01')
create(:resource_milestone_event, issue: issues[1], milestone: milestone, action: :add, created_at: '2020-01-05 05:00')
# Add milestone to already closed issue with weight 3. This should increment both the scope and completed totals.
create(:resource_weight_event, issue: issues[2], weight: 3, created_at: '2020-01-01')
create(:resource_state_event, issue: issues[2], state: :closed, created_at: '2020-01-05')
create(:resource_milestone_event, issue: issues[2], milestone: milestone, action: :add, created_at: '2020-01-06')
# Remove milestone from the 2nd open issue. This should decrement the scope totals.
create(:resource_milestone_event, issue: issues[1], milestone: milestone, action: :remove, created_at: '2020-01-07')
# Remove milestone from the closed issue. This should decrement both the scope and completed totals.
create(:resource_milestone_event, issue: issues[2], milestone: milestone, action: :remove, created_at: '2020-01-08')
# Adding a different milestone should not affect the data.
create(:resource_milestone_event, issue: issues[3], milestone: create(:milestone, project: project), action: :add, created_at: '2020-01-08')
# Adding the milestone after the due date should not affect the data.
create(:resource_milestone_event, issue: issues[4], milestone: milestone, action: :add, created_at: '2020-01-30')
# Removing the milestone after the due date should not affect the data.
create(:resource_milestone_event, issue: issues[0], milestone: milestone, action: :remove, created_at: '2020-01-30')
expect(chart_data).to eq([
{
date: Date.parse('2020-01-05'),
scope_count: 2,
scope_weight: 2,
completed_count: 0,
completed_weight: 0
},
{
date: Date.parse('2020-01-06'),
scope_count: 3,
scope_weight: 5,
completed_count: 1,
completed_weight: 3
},
{
date: Date.parse('2020-01-07'),
scope_count: 2,
scope_weight: 3,
completed_count: 1,
completed_weight: 3
},
{
date: Date.parse('2020-01-08'),
scope_count: 1,
scope_weight: 0,
completed_count: 0,
completed_weight: 0
}
])
end
it 'updates the completed counts when issue state is changed' do
# Close an issue assigned to the milestone with weight 2. This should increment the completed totals.
create(:resource_milestone_event, issue: issues[0], milestone: milestone, action: :add, created_at: '2020-01-01 01:00')
create(:resource_weight_event, issue: issues[0], weight: 2, created_at: '2020-01-01 02:00')
create(:resource_state_event, issue: issues[0], state: :closed, created_at: '2020-01-02')
# Closing an issue that is already closed should be ignored.
create(:resource_state_event, issue: issues[0], state: :closed, created_at: '2020-01-03')
# Re-opening the issue should decrement the completed totals.
create(:resource_state_event, issue: issues[0], state: :reopened, created_at: '2020-01-04')
# Closing and re-opening an issue on the same day should not change the totals.
create(:resource_milestone_event, issue: issues[1], milestone: milestone, action: :add, created_at: '2020-01-05 01:00')
create(:resource_weight_event, issue: issues[1], weight: 3, created_at: '2020-01-05 02:00')
create(:resource_state_event, issue: issues[1], state: :closed, created_at: '2020-01-06 05:00')
create(:resource_state_event, issue: issues[1], state: :reopened, created_at: '2020-01-06 08:00')
# Re-opening an issue that is already open should be ignored.
create(:resource_state_event, issue: issues[1], state: :reopened, created_at: '2020-01-07')
# Closing a re-opened issue should increment the completed totals.
create(:resource_state_event, issue: issues[1], state: :closed, created_at: '2020-01-08')
# Changing state when the milestone is already removed should not affect the data.
create(:resource_milestone_event, issue: issues[1], action: :remove, created_at: '2020-01-09')
create(:resource_state_event, issue: issues[1], state: :closed, created_at: '2020-01-10')
expect(chart_data).to eq([
{
date: Date.parse('2020-01-01'),
scope_count: 1,
scope_weight: 2,
completed_count: 0,
completed_weight: 0
},
{
date: Date.parse('2020-01-02'),
scope_count: 1,
scope_weight: 2,
completed_count: 1,
completed_weight: 2
},
{
date: Date.parse('2020-01-04'),
scope_count: 1,
scope_weight: 2,
completed_count: 0,
completed_weight: 0
},
{
date: Date.parse('2020-01-05'),
scope_count: 2,
scope_weight: 5,
completed_count: 0,
completed_weight: 0
},
{
date: Date.parse('2020-01-06'),
scope_count: 2,
scope_weight: 5,
completed_count: 0,
completed_weight: 0
},
{
date: Date.parse('2020-01-08'),
scope_count: 2,
scope_weight: 5,
completed_count: 1,
completed_weight: 3
},
{
date: Date.parse('2020-01-09'),
scope_count: 1,
scope_weight: 2,
completed_count: 0,
completed_weight: 0
}
])
end
it 'updates the weight totals when issue weight is changed' do
# Issue starts out with no weight and should increment once the weight is changed to 2.
create(:resource_milestone_event, issue: issues[0], milestone: milestone, action: :add, created_at: '2020-01-01')
create(:resource_weight_event, issue: issues[0], weight: 2, created_at: '2020-01-02')
# A closed issue is added and weight is set to 5 and should add to the weight totals.
create(:resource_milestone_event, issue: issues[1], milestone: milestone, action: :add, created_at: '2020-01-03 01:00')
create(:resource_state_event, issue: issues[1], state: :closed, created_at: '2020-01-03 02:00')
create(:resource_weight_event, issue: issues[1], weight: 5, created_at: '2020-01-03 03:00')
# Lowering the weight of the 2nd issue should decrement the weight totals.
create(:resource_weight_event, issue: issues[1], weight: 1, created_at: '2020-01-04')
# After the first issue is assigned to another milestone, weight changes shouldn't affect the data.
create(:resource_milestone_event, issue: issues[0], milestone: create(:milestone, project: project), action: :add, created_at: '2020-01-05')
create(:resource_weight_event, issue: issues[0], weight: 10, created_at: '2020-01-06')
expect(chart_data).to eq([
{
date: Date.parse('2020-01-01'),
scope_count: 1,
scope_weight: 0,
completed_count: 0,
completed_weight: 0
},
{
date: Date.parse('2020-01-02'),
scope_count: 1,
scope_weight: 2,
completed_count: 0,
completed_weight: 0
},
{
date: Date.parse('2020-01-03'),
scope_count: 2,
scope_weight: 7,
completed_count: 1,
completed_weight: 5
},
{
date: Date.parse('2020-01-04'),
scope_count: 2,
scope_weight: 3,
completed_count: 1,
completed_weight: 1
},
{
date: Date.parse('2020-01-05'),
scope_count: 1,
scope_weight: 1,
completed_count: 1,
completed_weight: 1
}
])
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment