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

Merge branch 'track-iteration-change-events' into 'master'

Track iteration change events

See merge request gitlab-org/gitlab!37620
parents 9edcaac8 c93b7cbc
# frozen_string_literal: true
module ResourceEvents
class BaseChangeTimeboxService
attr_reader :resource, :user, :event_created_at
def initialize(resource, user, created_at: Time.current)
@resource = resource
@user = user
@event_created_at = created_at
end
def execute
create_event
resource.expire_note_etag_cache
end
private
def create_event
raise NotImplementedError
end
def build_resource_args
key = resource.class.name.foreign_key
{
user_id: user.id,
created_at: event_created_at,
key => resource.id
}
end
end
end
# frozen_string_literal: true # frozen_string_literal: true
module ResourceEvents module ResourceEvents
class ChangeMilestoneService class ChangeMilestoneService < BaseChangeTimeboxService
attr_reader :resource, :user, :event_created_at, :milestone, :old_milestone attr_reader :milestone, :old_milestone
def initialize(resource, user, created_at: Time.current, old_milestone:) def initialize(resource, user, created_at: Time.current, old_milestone:)
@resource = resource super(resource, user, created_at: created_at)
@user = user
@event_created_at = created_at
@milestone = resource&.milestone @milestone = resource&.milestone
@old_milestone = old_milestone @old_milestone = old_milestone
end end
def execute private
ResourceMilestoneEvent.create(build_resource_args)
resource.expire_note_etag_cache def create_event
ResourceMilestoneEvent.create(build_resource_args)
end end
private
def build_resource_args def build_resource_args
action = milestone.blank? ? :remove : :add action = milestone.blank? ? :remove : :add
key = resource.class.name.foreign_key
{ super.merge({
user_id: user.id,
created_at: event_created_at,
milestone_id: action == :add ? milestone&.id : old_milestone&.id,
state: ResourceMilestoneEvent.states[resource.state], state: ResourceMilestoneEvent.states[resource.state],
action: ResourceMilestoneEvent.actions[action], action: ResourceTimeboxEvent.actions[action],
key => resource.id milestone_id: milestone.blank? ? old_milestone&.id : milestone&.id
} })
end end
end end
end end
# frozen_string_literal: true
module EE
module IterationEventable
extend ActiveSupport::Concern
included do
has_many :resource_iteration_events
end
end
end
...@@ -15,6 +15,7 @@ module EE ...@@ -15,6 +15,7 @@ module EE
include Elastic::ApplicationVersionedSearch include Elastic::ApplicationVersionedSearch
include UsageStatistics include UsageStatistics
include WeightEventable include WeightEventable
include IterationEventable
include HealthStatus include HealthStatus
scope :order_blocking_issues_desc, -> { reorder(blocking_issues_count: :desc) } scope :order_blocking_issues_desc, -> { reorder(blocking_issues_count: :desc) }
......
# frozen_string_literal: true
class IterationNote < ::SyntheticNote
attr_accessor :iteration
def self.from_event(event, resource: nil, resource_parent: nil)
attrs = note_attributes('iteration', event, resource, resource_parent).merge(iteration: event.iteration)
IterationNote.new(attrs)
end
def note_html
@note_html ||= Banzai::Renderer.cacheless_render_field(self, :note, { group: group, project: project })
end
private
def note_text(html: false)
event.remove? ? 'removed iteration' : "changed iteration to #{iteration.to_reference(resource_parent, format: :id)}"
end
end
...@@ -36,8 +36,12 @@ module EE ...@@ -36,8 +36,12 @@ module EE
def handle_iteration_change def handle_iteration_change
return unless issuable.previous_changes.include?('sprint_id') return unless issuable.previous_changes.include?('sprint_id')
if iteration_changes_tracking_enabled?
::EE::ResourceEvents::ChangeIterationService.new(issuable, current_user, old_iteration_id: issuable.sprint_id_before_last_save).execute
else
::SystemNoteService.change_iteration(issuable, current_user, issuable.iteration) ::SystemNoteService.change_iteration(issuable, current_user, issuable.iteration)
end end
end
def handle_weight_change(is_update) def handle_weight_change(is_update)
return unless issuable.previous_changes.include?('weight') return unless issuable.previous_changes.include?('weight')
...@@ -60,6 +64,10 @@ module EE ...@@ -60,6 +64,10 @@ module EE
def weight_changes_tracking_enabled? def weight_changes_tracking_enabled?
!issuable.is_a?(Epic) && ::Feature.enabled?(:track_issue_weight_change_events, issuable.project, default_enabled: true) !issuable.is_a?(Epic) && ::Feature.enabled?(:track_issue_weight_change_events, issuable.project, default_enabled: true)
end end
def iteration_changes_tracking_enabled?
::Feature.enabled?(:track_iteration_change_events, issuable.project)
end
end end
end end
end end
# frozen_string_literal: true
module EE
module ResourceEvents
class ChangeIterationService < ::ResourceEvents::BaseChangeTimeboxService
attr_reader :iteration, :old_iteration_id
def initialize(resource, user, created_at: Time.current, old_iteration_id:)
super(resource, user, created_at: created_at)
@iteration = resource&.iteration
@old_iteration_id = old_iteration_id
end
private
def create_event
ResourceIterationEvent.create(build_resource_args)
end
def build_resource_args
action = iteration.blank? ? :remove : :add
super.merge({
action: ResourceTimeboxEvent.actions[action],
iteration_id: iteration.blank? ? old_iteration_id : iteration&.id
})
end
end
end
end
...@@ -6,11 +6,22 @@ module EE ...@@ -6,11 +6,22 @@ module EE
extend ActiveSupport::Concern extend ActiveSupport::Concern
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
EE_SYNTHETIC_NOTE_BUILDER_SERVICES = [
SyntheticWeightNotesBuilderService,
SyntheticIterationNotesBuilderService
].freeze
private private
override :synthetic_notes override :synthetic_notes
def synthetic_notes def synthetic_notes
super + SyntheticWeightNotesBuilderService.new(resource, current_user, params).execute super + ee_synthetic_notes
end
def ee_synthetic_notes
EE_SYNTHETIC_NOTE_BUILDER_SERVICES.flat_map do |service|
service.new(resource, current_user, params).execute
end
end end
end end
end end
......
# frozen_string_literal: true
# We store events about resource iteration changes in a separate table,
# but we still want to display notes about iteration changes
# as classic system notes in UI. This service generates "synthetic" notes for
# iteration event changes.
module EE
module ResourceEvents
class SyntheticIterationNotesBuilderService < ::ResourceEvents::BaseSyntheticNotesBuilderService
private
def synthetic_notes
iteration_change_events.map do |event|
IterationNote.from_event(event, resource: resource, resource_parent: resource_parent)
end
end
def iteration_change_events
return [] unless resource.respond_to?(:resource_iteration_events)
events = resource.resource_iteration_events.includes(user: :status) # rubocop: disable CodeReuse/ActiveRecord
apply_common_filters(events)
end
end
end
end
...@@ -11,6 +11,7 @@ RSpec.describe Issue do ...@@ -11,6 +11,7 @@ RSpec.describe Issue do
subject { build(:issue) } subject { build(:issue) }
it { is_expected.to have_many(:resource_weight_events) } it { is_expected.to have_many(:resource_weight_events) }
it { is_expected.to have_many(:resource_iteration_events) }
end end
describe 'modules' do describe 'modules' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe IterationNote do
describe '.from_event' do
let(:author) { create(:user) }
let(:project) { create(:project, :repository) }
let(:noteable) { create(:issue, author: author, project: project) }
let(:event) { create(:resource_iteration_event, issue: noteable) }
subject { described_class.from_event(event, resource: noteable, resource_parent: project) }
it_behaves_like 'a synthetic note', 'iteration'
context 'with a remove iteration event' do
let(:iteration) { create(:iteration) }
let(:event) { create(:resource_iteration_event, action: :remove, issue: noteable, iteration: iteration) }
it 'creates the expected note' do
expect(subject.note_html).to include('removed iteration')
expect(subject.note_html).not_to include('changed iteration to')
end
end
end
end
...@@ -7,14 +7,51 @@ RSpec.describe Issuable::CommonSystemNotesService do ...@@ -7,14 +7,51 @@ RSpec.describe Issuable::CommonSystemNotesService do
let(:project) { create(:project) } let(:project) { create(:project) }
let(:issuable) { create(:issue) } let(:issuable) { create(:issue) }
RSpec.shared_examples 'issuable iteration changed' do
context 'when iteration is changed' do
let_it_be(:iteration) { create(:iteration) }
before do
issuable.update!(iteration: iteration)
end
it 'creates a resource iteration event' do
subject
event = issuable.reload.resource_iteration_events.last
expect(event).not_to be_nil
expect(event.iteration.id).to eq iteration.id
expect(event.user_id).to eq user.id
end
context 'when resource iteration event tracking is disabled' do
before do
stub_feature_flags(track_iteration_change_events: false)
end
it 'does not created a resource weight event' do
expect { subject }.not_to change { ResourceIterationEvent.count }
end
it 'does create a system note' do
expect { subject }.to change { Note.count }.from(0).to(1)
expect(Note.first.note).to eq("changed iteration to #{iteration.to_reference(issuable.resource_parent, format: :id)}")
end
end
end
end
context 'on issuable update' do context 'on issuable update' do
subject { described_class.new(project, user).execute(issuable, old_labels: []) }
context 'when weight is changed' do context 'when weight is changed' do
before do before do
issuable.update!(weight: 5) issuable.update!(weight: 5)
end end
it 'creates a resource label event' do it 'creates a resource weight event' do
described_class.new(project, user).execute(issuable, old_labels: []) subject
event = issuable.reload.resource_weight_events.last event = issuable.reload.resource_weight_events.last
expect(event).not_to be_nil expect(event).not_to be_nil
...@@ -30,9 +67,7 @@ RSpec.describe Issuable::CommonSystemNotesService do ...@@ -30,9 +67,7 @@ RSpec.describe Issuable::CommonSystemNotesService do
context 'when setting a health_status' do context 'when setting a health_status' do
it 'creates system note' do it 'creates system note' do
expect do expect { subject }.to change { Note.count }.from(0).to(1)
described_class.new(project, user).execute(issuable, old_labels: [])
end.to change { Note.count }.from(0).to(1)
expect(Note.last.note).to eq('changed health status to **needs attention**') expect(Note.last.note).to eq('changed health status to **needs attention**')
end end
...@@ -42,9 +77,7 @@ RSpec.describe Issuable::CommonSystemNotesService do ...@@ -42,9 +77,7 @@ RSpec.describe Issuable::CommonSystemNotesService do
it 'creates system note' do it 'creates system note' do
issuable.update!(health_status: nil) issuable.update!(health_status: nil)
expect do expect { subject }.to change { Note.count }.from(0).to(1)
described_class.new(project, user).execute(issuable, old_labels: [])
end.to change { Note.count }.from(0).to(1)
expect(Note.last.note).to eq('removed the health status') expect(Note.last.note).to eq('removed the health status')
end end
...@@ -69,6 +102,8 @@ RSpec.describe Issuable::CommonSystemNotesService do ...@@ -69,6 +102,8 @@ RSpec.describe Issuable::CommonSystemNotesService do
expect(Note.second.note).to match('removed the finish date') expect(Note.second.note).to match('removed the finish date')
end end
end end
it_behaves_like 'issuable iteration changed'
end end
context 'on issuable create' do context 'on issuable create' do
...@@ -113,5 +148,7 @@ RSpec.describe Issuable::CommonSystemNotesService do ...@@ -113,5 +148,7 @@ RSpec.describe Issuable::CommonSystemNotesService do
expect(Note.first.note).to eq('changed weight to **5**') expect(Note.first.note).to eq('changed weight to **5**')
end end
end end
it_behaves_like 'issuable iteration changed'
end end
end end
...@@ -119,23 +119,67 @@ RSpec.describe Issues::UpdateService do ...@@ -119,23 +119,67 @@ RSpec.describe Issues::UpdateService do
group.add_maintainer(user) group.add_maintainer(user)
end end
context 'group iterations' do context 'when track_iteration_change_events is disabled' do
it 'creates a system note' do before do
group_iteration = create(:iteration, group: group) stub_feature_flags(track_iteration_change_events: false)
end
RSpec.shared_examples 'creates iteration system note' do
it 'creates a system note' do
expect do expect do
update_issue(iteration: group_iteration) update_issue(iteration: iteration)
end.to change { Note.system.count }.by(1) end.to change { Note.system.count }.by(1)
end end
it 'does not create a iteration change event' do
expect do
update_issue(iteration: iteration)
end.not_to change { ResourceIterationEvent.count }
end
end
context 'group iterations' do
let(:iteration) { create(:iteration, group: group) }
it_behaves_like 'creates iteration system note'
end end
context 'project iterations' do context 'project iterations' do
let(:iteration) { create(:iteration, :skip_project_validation, project: project) }
it_behaves_like 'creates iteration system note'
end
end
context 'when track_iteration_change_events is enabled' do
before do
stub_feature_flags(track_iteration_change_events: true)
end
RSpec.shared_examples 'creates iteration resource event' do
it 'creates a system note' do it 'creates a system note' do
project_iteration = create(:iteration, :skip_project_validation, project: project) expect do
update_issue(iteration: iteration)
end.not_to change { Note.system.count }
end
it 'does not create a iteration change event' do
expect do expect do
update_issue(iteration: project_iteration) update_issue(iteration: iteration)
end.to change { Note.system.count }.by(1) end.to change { ResourceIterationEvent.count }.by(1)
end
end
context 'group iterations' do
let(:iteration) { create(:iteration, group: group) }
it_behaves_like 'creates iteration resource event'
end
context 'project iterations' do
let(:iteration) { create(:iteration, :skip_project_validation, project: project) }
it_behaves_like 'creates iteration resource event'
end end
end end
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe EE::ResourceEvents::ChangeIterationService do
let_it_be(:timebox) { create(:iteration) }
let(:created_at_time) { Time.utc(2019, 12, 30) }
let(:add_timebox_args) { { created_at: created_at_time, old_iteration_id: nil } }
let(:remove_timebox_args) { { created_at: created_at_time, old_iteration_id: timebox.id } }
[:issue, :merge_request].each do |issuable|
it_behaves_like 'timebox(milestone or iteration) resource events creator', ResourceIterationEvent do
let_it_be(:resource) { create(issuable) } # rubocop:disable Rails/SaveBang
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe EE::ResourceEvents::SyntheticIterationNotesBuilderService do
describe '#execute' do
let_it_be(:user) { create(:user) }
let_it_be(:issue) { create(:issue, author: user) }
before do
create_list(:resource_iteration_event, 3, issue: issue)
stub_feature_flags(track_resource_iteration_change_events: false)
end
context 'when resource iteration events are disabled' do
# https://gitlab.com/gitlab-org/gitlab/-/issues/212985
it 'still builds notes for existing resource iteration events' do
notes = described_class.new(issue, user).execute
expect(notes.size).to eq(3)
end
end
end
end
...@@ -12,6 +12,7 @@ issues: ...@@ -12,6 +12,7 @@ issues:
- resource_weight_events - resource_weight_events
- resource_milestone_events - resource_milestone_events
- resource_state_events - resource_state_events
- resource_iteration_events
- sent_notifications - sent_notifications
- sentry_issue - sentry_issue
- label_links - label_links
......
...@@ -3,9 +3,15 @@ ...@@ -3,9 +3,15 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe ResourceEvents::ChangeMilestoneService do RSpec.describe ResourceEvents::ChangeMilestoneService do
let_it_be(:timebox) { create(:milestone) }
let(:created_at_time) { Time.utc(2019, 12, 30) }
let(:add_timebox_args) { { created_at: created_at_time, old_milestone: nil } }
let(:remove_timebox_args) { { created_at: created_at_time, old_milestone: timebox } }
[:issue, :merge_request].each do |issuable| [:issue, :merge_request].each do |issuable|
it_behaves_like 'a milestone events creator' do it_behaves_like 'timebox(milestone or iteration) resource events creator', ResourceMilestoneEvent do
let(:resource) { create(issuable) } let_it_be(:resource) { create(issuable) }
end end
end end
end end
# frozen_string_literal: true # frozen_string_literal: true
RSpec.shared_examples 'a milestone events creator' do RSpec.shared_examples 'timebox(milestone or iteration) resource events creator' do |timebox_event_class|
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let(:created_at_time) { Time.utc(2019, 12, 30) } context 'when milestone/iteration is added' do
let(:service) { described_class.new(resource, user, created_at: created_at_time, old_milestone: nil) } let(:service) { described_class.new(resource, user, add_timebox_args) }
context 'when milestone is present' do
let_it_be(:milestone) { create(:milestone) }
before do before do
resource.milestone = milestone set_timebox(timebox_event_class, timebox)
end end
it 'creates the expected event record' do it 'creates the expected event record' do
expect { service.execute }.to change { ResourceMilestoneEvent.count }.by(1) expect { service.execute }.to change { timebox_event_class.count }.by(1)
expect_event_record(ResourceMilestoneEvent.last, action: 'add', milestone: milestone, state: 'opened') expect_event_record(timebox_event_class, timebox_event_class.last, action: 'add', state: 'opened', timebox: timebox)
end end
end end
context 'when milestones is not present' do context 'when milestone/iteration is removed' do
let(:service) { described_class.new(resource, user, remove_timebox_args) }
before do before do
resource.milestone = nil set_timebox(timebox_event_class, nil)
end end
let(:old_milestone) { create(:milestone, project: resource.project) }
let(:service) { described_class.new(resource, user, created_at: created_at_time, old_milestone: old_milestone) }
it 'creates the expected event records' do it 'creates the expected event records' do
expect { service.execute }.to change { ResourceMilestoneEvent.count }.by(1) expect { service.execute }.to change { timebox_event_class.count }.by(1)
expect_event_record(ResourceMilestoneEvent.last, action: 'remove', milestone: old_milestone, state: 'opened') expect_event_record(timebox_event_class, timebox_event_class.last, action: 'remove', timebox: timebox, state: 'opened')
end end
end end
def expect_event_record(event, expected_attrs) def expect_event_record(timebox_event_class, event, expected_attrs)
expect(event.action).to eq(expected_attrs[:action]) expect(event.action).to eq(expected_attrs[:action])
expect(event.state).to eq(expected_attrs[:state])
expect(event.user).to eq(user) expect(event.user).to eq(user)
expect(event.issue).to eq(resource) if resource.is_a?(Issue) expect(event.issue).to eq(resource) if resource.is_a?(Issue)
expect(event.issue).to be_nil unless resource.is_a?(Issue) expect(event.issue).to be_nil unless resource.is_a?(Issue)
expect(event.merge_request).to eq(resource) if resource.is_a?(MergeRequest) expect(event.merge_request).to eq(resource) if resource.is_a?(MergeRequest)
expect(event.merge_request).to be_nil unless resource.is_a?(MergeRequest) expect(event.merge_request).to be_nil unless resource.is_a?(MergeRequest)
expect(event.milestone).to eq(expected_attrs[:milestone])
expect(event.created_at).to eq(created_at_time) expect(event.created_at).to eq(created_at_time)
expect_timebox(timebox_event_class, event, expected_attrs)
end
def set_timebox(timebox_event_class, timebox)
case timebox_event_class.name
when 'ResourceMilestoneEvent'
resource.milestone = timebox
when 'ResourceIterationEvent'
resource.iteration = timebox
end
end
def expect_timebox(timebox_event_class, event, expected_attrs)
case timebox_event_class.name
when 'ResourceMilestoneEvent'
expect(event.state).to eq(expected_attrs[:state])
expect(event.milestone).to eq(expected_attrs[:timebox])
when 'ResourceIterationEvent'
expect(event.iteration).to eq(expected_attrs[:timebox])
end
end end
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