Commit 930cf7bc authored by Patrick Derichs's avatar Patrick Derichs

Add weights to burnup chart data

Add weights to graph data

Use union of events to reduce queries

Extract scopes and use Gitlab::SQL::Union

Refactor and extract logic out of resource events

Apply suggestion to app/models/concerns/issue_resource_event.rb
Apply suggestion to ee/app/services/burnup_chart_service.rb
Apply suggestion to ee/app/services/burnup_chart_service.rb
Change scope name

Make attribute readers private

Use field names instead of index access
parent d6b16e5f
# frozen_string_literal: true
module IssueResourceEvent
extend ActiveSupport::Concern
included do
belongs_to :issue
scope :by_issue, ->(issue) { where(issue_id: issue.id) }
scope :by_issue_ids_and_created_at_earlier_or_equal_to, ->(issue_ids, time) { where(issue_id: issue_ids).where('created_at <= ?', time) }
end
end
# frozen_string_literal: true
module MergeRequestResourceEvent
extend ActiveSupport::Concern
included do
belongs_to :merge_request
scope :by_merge_request, ->(merge_request) { where(merge_request_id: merge_request.id) }
end
end
...@@ -2,16 +2,14 @@ ...@@ -2,16 +2,14 @@
class ResourceLabelEvent < ResourceEvent class ResourceLabelEvent < ResourceEvent
include CacheMarkdownField include CacheMarkdownField
include IssueResourceEvent
include MergeRequestResourceEvent
cache_markdown_field :reference cache_markdown_field :reference
belongs_to :issue
belongs_to :merge_request
belongs_to :label belongs_to :label
scope :inc_relations, -> { includes(:label, :user) } scope :inc_relations, -> { includes(:label, :user) }
scope :by_issue, ->(issue) { where(issue_id: issue.id) }
scope :by_merge_request, ->(merge_request) { where(merge_request_id: merge_request.id) }
validates :label, presence: { unless: :importing? }, on: :create validates :label, presence: { unless: :importing? }, on: :create
validate :exactly_one_issuable validate :exactly_one_issuable
......
...@@ -2,14 +2,11 @@ ...@@ -2,14 +2,11 @@
class ResourceMilestoneEvent < ResourceEvent class ResourceMilestoneEvent < ResourceEvent
include IgnorableColumns include IgnorableColumns
include IssueResourceEvent
include MergeRequestResourceEvent
belongs_to :issue
belongs_to :merge_request
belongs_to :milestone belongs_to :milestone
scope :by_issue, ->(issue) { where(issue_id: issue.id) }
scope :by_merge_request, ->(merge_request) { where(merge_request_id: merge_request.id) }
validate :exactly_one_issuable validate :exactly_one_issuable
enum action: { enum action: {
......
...@@ -3,7 +3,5 @@ ...@@ -3,7 +3,5 @@
class ResourceWeightEvent < ResourceEvent class ResourceWeightEvent < ResourceEvent
validates :issue, presence: true validates :issue, presence: true
belongs_to :issue include IssueResourceEvent
scope :by_issue, ->(issue) { where(issue_id: issue.id) }
end end
...@@ -3,7 +3,12 @@ ...@@ -3,7 +3,12 @@
class BurnupChartService class BurnupChartService
include Gitlab::Utils::StrongMemoize include Gitlab::Utils::StrongMemoize
attr_reader :milestone, :start_date, :due_date, :end_date, :user EVENT_TYPE = 'event_type'.freeze
CREATED_AT = 'created_at'.freeze
MILESTONE_ID = 'value'.freeze
WEIGHT = 'value'.freeze
ACTION = 'action'.freeze
ISSUE_ID = 'issue_id'.freeze
def initialize(milestone:, user:) def initialize(milestone:, user:)
@user = user @user = user
...@@ -15,10 +20,12 @@ class BurnupChartService ...@@ -15,10 +20,12 @@ class BurnupChartService
def execute def execute
return [] unless milestone.burnup_charts_available? return [] unless milestone.burnup_charts_available?
return [] unless can_read_milestone?
events = [] events = []
assigned_milestones = {} assigned_milestones = {}
resource_milestone_events.each do |event| resource_events.each do |event|
handle_added_milestone(event, assigned_milestones) handle_added_milestone(event, assigned_milestones)
events << create_burnup_graph_event_by(event, assigned_milestones) events << create_burnup_graph_event_by(event, assigned_milestones)
...@@ -31,27 +38,76 @@ class BurnupChartService ...@@ -31,27 +38,76 @@ class BurnupChartService
private private
attr_reader :milestone, :start_date, :due_date, :end_date, :user
def can_read_milestone?
Ability.allowed?(user, :read_milestone, milestone_parent)
end
def milestone_parent
if milestone.group_milestone?
milestone.group
else
milestone.project
end
end
def handle_added_milestone(event, assigned_milestones) def handle_added_milestone(event, assigned_milestones)
if event.add? if add_milestone?(event)
assigned_milestones[event.issue_id] = event.milestone_id assigned_milestones[event[ISSUE_ID]] = event[MILESTONE_ID]
end end
end end
def handle_removed_milestone(event, assigned_milestones) def handle_removed_milestone(event, assigned_milestones)
if event.remove? if remove_milestone?(event)
assigned_milestones[event.issue_id] = nil assigned_milestones[event[ISSUE_ID]] = nil
end end
end end
def resource_events
strong_memoize(:resource_events) do
union = Gitlab::SQL::Union.new(all_events).to_sql # rubocop: disable Gitlab/Union
ActiveRecord::Base.connection.execute("(#{union}) ORDER BY created_at")
end
end
def all_events
[milestone_events, weight_events]
end
def weight_events
ResourceWeightEvent.by_issue_ids_and_created_at_earlier_or_equal_to(relevant_issue_ids, end_time)
.select('\'weight\' as event_type, created_at, weight as value, null as action, issue_id')
end
def milestone_events
ResourceMilestoneEvent.by_issue_ids_and_created_at_earlier_or_equal_to(relevant_issue_ids, end_time)
.select('\'milestone\' as event_type, created_at, milestone_id as value, action, issue_id')
end
def create_burnup_graph_event_by(event, assigned_milestones) def create_burnup_graph_event_by(event, assigned_milestones)
{ {
created_at: event.created_at, event_type: event[EVENT_TYPE],
action: event.action, created_at: event[CREATED_AT],
action: action_of(event),
milestone_id: milestone_id_of(event, assigned_milestones), milestone_id: milestone_id_of(event, assigned_milestones),
issue_id: event.issue_id issue_id: event[ISSUE_ID],
weight: weight_of(event)
} }
end end
def action_of(event)
return unless milestone_event?(event)
ResourceMilestoneEvent.actions.key(event[ACTION])
end
def weight_of(event)
return unless event[EVENT_TYPE] == 'weight'
event[WEIGHT]
end
def assign_dates_by_milestone def assign_dates_by_milestone
@start_date = milestone.start_date @start_date = milestone.start_date
@due_date = milestone.due_date @due_date = milestone.due_date
...@@ -63,45 +119,43 @@ class BurnupChartService ...@@ -63,45 +119,43 @@ class BurnupChartService
end end
def milestone_id_of(event, assigned_milestones) def milestone_id_of(event, assigned_milestones)
if event.remove? && event.milestone_id.nil? if remove_milestone?(event) && event[MILESTONE_ID].nil?
return assigned_milestones[event.issue_id] return assigned_milestones[event[ISSUE_ID]]
end end
event.milestone_id event[MILESTONE_ID]
end end
# This is just temporarily disabled until https://gitlab.com/gitlab-org/gitlab/-/merge_requests/29578 def add_milestone?(event)
# is merged. return unless milestone_event?(event)
# rubocop: disable CodeReuse/ActiveRecord
def resource_milestone_events event[ACTION] == ResourceMilestoneEvent.actions[:add]
# Here we use the relevant issues to get *all* milestone events for end
# them.
strong_memoize(:resource_milestone_events) do def remove_milestone?(event)
ResourceMilestoneEvent return unless milestone_event?(event)
.where(issue_id: relevant_issue_ids)
.where('created_at <= ?', end_time) event[ACTION] == ResourceMilestoneEvent.actions[:remove]
.order(:created_at)
end
end end
def milestone_event?(event)
event[EVENT_TYPE] == 'milestone'
end
# rubocop: disable CodeReuse/ActiveRecord
def relevant_issue_ids def relevant_issue_ids
# We are using all resource milestone events where the # We are using all resource milestone events where the
# milestone in question was added to identify the relevant # milestone in question was added to identify the relevant
# issues. # issues.
strong_memoize(:relevant_issue_ids) do strong_memoize(:relevant_issue_ids) do
ids = ResourceMilestoneEvent ResourceMilestoneEvent
.select(:issue_id) .select(:issue_id)
.where(milestone_id: milestone.id) .where(milestone_id: milestone.id)
.where(action: :add) .where(action: :add)
.distinct .distinct
# We need to perform an additional check whether all these
# issues are visible to the given user
IssuesFinder.new(user)
.execute.select(:id).where(id: ids)
end end
# rubocop: enable CodeReuse/ActiveRecord
end end
# rubocop: enable CodeReuse/ActiveRecord
def end_time def end_time
@end_time ||= @end_date.end_of_day @end_time ||= @end_date.end_of_day
......
...@@ -8,6 +8,12 @@ ...@@ -8,6 +8,12 @@
"issue_id" "issue_id"
], ],
"properties": { "properties": {
"event_type": {
"type": { "enum": [ "milestone", "weight" ] }
},
"weight": {
"type": ["integer", "null"]
},
"milestone_id": { "milestone_id": {
"type": ["integer", "null"] "type": ["integer", "null"]
}, },
...@@ -15,7 +21,7 @@ ...@@ -15,7 +21,7 @@
"type": "integer" "type": "integer"
}, },
"action": { "action": {
"type": "string" "type": ["string", "null"]
}, },
"created_at": { "created_at": {
"type": "date" "type": "date"
......
...@@ -19,8 +19,14 @@ describe BurnupChartService do ...@@ -19,8 +19,14 @@ describe BurnupChartService do
let_it_be(:issue3) { create(:issue, project: project, milestone: milestone1) } let_it_be(:issue3) { create(:issue, project: project, milestone: milestone1) }
let_it_be(:other_issue) { create(:issue, project: project) } let_it_be(:other_issue) { create(:issue, project: project) }
let_it_be(:weight_event1) { create(:resource_weight_event, issue: issue1, weight: 9, created_at: start_date + 2.seconds) }
let_it_be(:weight_event2) { create(:resource_weight_event, issue: issue2, weight: 3, created_at: start_date + 3.seconds) }
let_it_be(:weight_event3) { create(:resource_weight_event, issue: issue3, weight: 1, created_at: start_date + 2.days) }
let_it_be(:weight_event4) { create(:resource_weight_event, issue: issue3, weight: 2, created_at: start_date + 2.days + 23.hours + 59.minutes + 59.seconds) }
let_it_be(:weight_event5) { create(:resource_weight_event, issue: issue3, weight: 7, created_at: start_date + 4.days + 1.second) }
let_it_be(:event1) { create(:resource_milestone_event, issue: issue1, action: :add, milestone: milestone1, created_at: start_date + 1.second) } let_it_be(:event1) { create(:resource_milestone_event, issue: issue1, action: :add, milestone: milestone1, created_at: start_date + 1.second) }
let_it_be(:event2) { create(:resource_milestone_event, issue: issue2, action: :add, milestone: milestone1, created_at: start_date + 2.seconds) } let_it_be(:event2) { create(:resource_milestone_event, issue: issue2, action: :add, milestone: milestone1, created_at: start_date + 4.seconds) }
let_it_be(:event3) { create(:resource_milestone_event, issue: issue3, action: :add, milestone: milestone1, created_at: start_date + 1.day) } let_it_be(:event3) { create(:resource_milestone_event, issue: issue3, action: :add, milestone: milestone1, created_at: start_date + 1.day) }
let_it_be(:event4) { create(:resource_milestone_event, issue: issue3, action: :remove, milestone: nil, created_at: start_date + 2.days + 1.second) } let_it_be(:event4) { create(:resource_milestone_event, issue: issue3, action: :remove, milestone: nil, created_at: start_date + 2.days + 1.second) }
let_it_be(:event5) { create(:resource_milestone_event, issue: issue3, action: :add, milestone: milestone2, created_at: start_date + 3.days) } let_it_be(:event5) { create(:resource_milestone_event, issue: issue3, action: :add, milestone: milestone2, created_at: start_date + 3.days) }
...@@ -43,17 +49,20 @@ describe BurnupChartService do ...@@ -43,17 +49,20 @@ describe BurnupChartService do
data = described_class.new(milestone: milestone1, user: user).execute data = described_class.new(milestone: milestone1, user: user).execute
expected_events = [ expect(data.size).to eq(12)
{ action: 'add', issue_id: issue1.id, milestone_id: milestone2.id, created_at: start_date.beginning_of_day - 1.second },
{ action: 'add', issue_id: issue1.id, milestone_id: milestone1.id, created_at: start_date + 1.second }, expect(data[0]).to include(event_type: 'milestone', action: 'add', issue_id: issue1.id, milestone_id: milestone2.id, created_at: start_date.beginning_of_day - 1.second)
{ action: 'add', issue_id: issue2.id, milestone_id: milestone1.id, created_at: start_date + 2.seconds }, expect(data[1]).to include(event_type: 'milestone', action: 'add', issue_id: issue1.id, milestone_id: milestone1.id, created_at: start_date + 1.second)
{ action: 'add', issue_id: issue3.id, milestone_id: milestone1.id, created_at: start_date + 1.day }, expect(data[2]).to include(event_type: 'weight', issue_id: issue1.id, weight: 9, created_at: start_date + 2.seconds)
{ action: 'remove', issue_id: issue3.id, milestone_id: milestone1.id, created_at: start_date + 2.days + 1.second }, expect(data[3]).to include(event_type: 'weight', issue_id: issue2.id, weight: 3, created_at: start_date + 3.seconds)
{ action: 'add', issue_id: issue3.id, milestone_id: milestone2.id, created_at: start_date + 3.days }, expect(data[4]).to include(event_type: 'milestone', action: 'add', issue_id: issue2.id, milestone_id: milestone1.id, created_at: start_date + 4.seconds)
{ action: 'remove', issue_id: issue3.id, milestone_id: milestone2.id, created_at: start_date + 4.days } expect(data[5]).to include(event_type: 'milestone', action: 'add', issue_id: issue3.id, milestone_id: milestone1.id, created_at: start_date + 1.day)
] expect(data[6]).to include(event_type: 'weight', issue_id: issue3.id, weight: 1, created_at: start_date + 2.days)
expect(data[7]).to include(event_type: 'milestone', action: 'remove', issue_id: issue3.id, milestone_id: milestone1.id, created_at: start_date + 2.days + 1.second)
expect(data).to eq(expected_events) expect(data[8]).to include(event_type: 'weight', issue_id: issue3.id, weight: 2, created_at: start_date + 2.days + 23.hours + 59.minutes + 59.seconds)
expect(data[9]).to include(event_type: 'milestone', action: 'add', issue_id: issue3.id, milestone_id: milestone2.id, created_at: start_date + 3.days)
expect(data[10]).to include(event_type: 'milestone', action: 'remove', issue_id: issue3.id, milestone_id: milestone2.id, created_at: start_date + 4.days)
expect(data[11]).to include(event_type: 'weight', issue_id: issue3.id, weight: 7, created_at: start_date + 4.days + 1.second)
end end
it 'excludes issues which should not be visible to the user ' do it 'excludes issues which should not be visible to the user ' do
......
...@@ -17,13 +17,11 @@ RSpec.shared_examples 'group and project milestone burnups' do |route_definition ...@@ -17,13 +17,11 @@ RSpec.shared_examples 'group and project milestone burnups' do |route_definition
expect(json_response).to be_an Array expect(json_response).to be_an Array
expect(json_response).to match_schema('burnup_events', dir: 'ee') expect(json_response).to match_schema('burnup_events', dir: 'ee')
expected_events = [ expect(json_response.size).to eq(3)
{ 'issue_id' => issue1.id, 'milestone_id' => milestone.id, 'action' => 'add', 'created_at' => event1.created_at.iso8601(3) },
{ 'issue_id' => issue2.id, 'milestone_id' => milestone.id, 'action' => 'add', 'created_at' => event2.created_at.iso8601(3) },
{ 'issue_id' => issue1.id, 'milestone_id' => milestone.id, 'action' => 'remove', 'created_at' => event3.created_at.iso8601(3) }
]
expect(json_response).to eq(expected_events) expect(json_response.first).to include('issue_id' => issue1.id, 'milestone_id' => milestone.id, 'action' => 'add', 'created_at' => event_time - 1.hour)
expect(json_response.second).to include('issue_id' => issue2.id, 'milestone_id' => milestone.id, 'action' => 'add', 'created_at' => event_time + 1.hour)
expect(json_response.third).to include('issue_id' => issue1.id, 'milestone_id' => milestone.id, 'action' => 'remove', 'created_at' => event_time + 2.hours)
end end
it 'returns 404 when user is not authorized to read milestone' do it 'returns 404 when user is not authorized to read milestone' do
......
...@@ -15,9 +15,6 @@ RSpec.describe ResourceLabelEvent, type: :model do ...@@ -15,9 +15,6 @@ RSpec.describe ResourceLabelEvent, type: :model do
it_behaves_like 'a resource event for merge requests' it_behaves_like 'a resource event for merge requests'
describe 'associations' do describe 'associations' do
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:issue) }
it { is_expected.to belong_to(:merge_request) }
it { is_expected.to belong_to(:label) } it { is_expected.to belong_to(:label) }
end end
......
...@@ -83,6 +83,24 @@ shared_examples 'a resource event for issues' do ...@@ -83,6 +83,24 @@ shared_examples 'a resource event for issues' do
expect(events).to be_empty expect(events).to be_empty
end end
end end
describe '.by_issue_ids_and_created_at_earlier_or_equal_to' do
let_it_be(:event1) { create(described_class.name.underscore.to_sym, issue: issue1, created_at: '2020-03-10') }
let_it_be(:event2) { create(described_class.name.underscore.to_sym, issue: issue2, created_at: '2020-03-10') }
let_it_be(:event3) { create(described_class.name.underscore.to_sym, issue: issue1, created_at: '2020-03-12') }
it 'returns the expected records for an issue with events' do
events = described_class.by_issue_ids_and_created_at_earlier_or_equal_to([issue1.id, issue2.id], '2020-03-11 23:59:59')
expect(events).to contain_exactly(event1, event2)
end
it 'returns the expected records for an issue with no events' do
events = described_class.by_issue_ids_and_created_at_earlier_or_equal_to(issue3, '2020-03-12')
expect(events).to be_empty
end
end
end end
shared_examples 'a resource event for merge requests' do shared_examples 'a resource event for merge requests' 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