From 26adb8bbde87a35bf1e6ff7ca42060ac8cccca1f Mon Sep 17 00:00:00 2001
From: Zakir Dzhamaliddinov <zakir.dzhamaliddinov@gmail.com>
Date: Thu, 16 Nov 2023 11:43:20 +0000
Subject: [PATCH] Include merge request approved events in Profile heat map

https://gitlab.com/gitlab-org/gitlab/-/issues/378613

Changelog: fixed
---
 app/models/event.rb                           |  25 ++-
 app/models/user.rb                            |   2 +-
 .../contributions_calendar_refactoring.yml    |   8 +
 lib/gitlab/contributions_calendar.rb          | 101 ++++++++---
 .../lib/gitlab/contributions_calendar_spec.rb | 163 ++++++++++--------
 spec/models/event_spec.rb                     |  53 +++++-
 6 files changed, 244 insertions(+), 108 deletions(-)
 create mode 100644 config/feature_flags/development/contributions_calendar_refactoring.yml

diff --git a/app/models/event.rb b/app/models/event.rb
index 7de7ad8ccd63b..3ff5a46f2d976 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -89,10 +89,26 @@ class Event < ApplicationRecord
   scope :for_wiki_page, -> { where(target_type: 'WikiPage::Meta') }
   scope :for_design, -> { where(target_type: 'DesignManagement::Design') }
   scope :for_issue, -> { where(target_type: ISSUE_TYPES) }
+  scope :for_merge_request, -> { where(target_type: 'MergeRequest') }
   scope :for_fingerprint, ->(fingerprint) do
     fingerprint.present? ? where(fingerprint: fingerprint) : none
   end
   scope :for_action, ->(action) { where(action: action) }
+  scope :created_between, ->(start_time, end_time) { where(created_at: start_time..end_time) }
+  scope :count_by_dates, ->(date_interval) { group("DATE(created_at + #{date_interval})").count }
+
+  scope :contributions, -> do
+    contribution_actions = [actions[:pushed], actions[:commented]]
+
+    contributable_target_types = %w[MergeRequest Issue WorkItem]
+    target_contribution_actions = [actions[:created], actions[:closed], actions[:merged], actions[:approved]]
+
+    where(
+      'action IN (?) OR (target_type IN (?) AND action IN (?))',
+      contribution_actions,
+      contributable_target_types, target_contribution_actions
+    )
+  end
 
   scope :with_associations, -> do
     # We're using preload for "push_event_payload" as otherwise the association
@@ -133,15 +149,6 @@ def find_sti_class(action)
       end
     end
 
-    # Update Gitlab::ContributionsCalendar#activity_dates if this changes
-    def contributions
-      where(
-        'action IN (?) OR (target_type IN (?) AND action IN (?))',
-        [actions[:pushed], actions[:commented]],
-        %w[MergeRequest Issue WorkItem], [actions[:created], actions[:closed], actions[:merged]]
-      )
-    end
-
     def limit_recent(limit = 20, offset = nil)
       recent.limit(limit).offset(offset)
     end
diff --git a/app/models/user.rb b/app/models/user.rb
index 25f2256313665..9c099a5dfcee9 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1776,7 +1776,7 @@ def oauth_authorized_tokens
   def contributed_projects
     events = Event.select(:project_id)
       .contributions.where(author_id: self)
-      .where("created_at > ?", Time.current - 1.year)
+      .created_after(Time.current - 1.year)
       .distinct
       .reorder(nil)
 
diff --git a/config/feature_flags/development/contributions_calendar_refactoring.yml b/config/feature_flags/development/contributions_calendar_refactoring.yml
new file mode 100644
index 0000000000000..e779939cc28e9
--- /dev/null
+++ b/config/feature_flags/development/contributions_calendar_refactoring.yml
@@ -0,0 +1,8 @@
+---
+name: contributions_calendar_refactoring
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/134991
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/429648
+milestone: '16.6'
+type: development
+group: group::tenant scale
+default_enabled: false
diff --git a/lib/gitlab/contributions_calendar.rb b/lib/gitlab/contributions_calendar.rb
index 2068a9ae7d518..c91fdd59c578b 100644
--- a/lib/gitlab/contributions_calendar.rb
+++ b/lib/gitlab/contributions_calendar.rb
@@ -3,6 +3,7 @@
 module Gitlab
   class ContributionsCalendar
     include TimeZoneHelper
+    include ::Gitlab::Utils::StrongMemoize
 
     attr_reader :contributor
     attr_reader :current_user
@@ -16,18 +17,23 @@ def initialize(contributor, current_user = nil)
         .execute(current_user, ignore_visibility: @contributor.include_private_contributions?)
     end
 
-    # rubocop: disable CodeReuse/ActiveRecord
     def activity_dates
-      return {} if @projects.empty?
-      return @activity_dates if @activity_dates.present?
+      return {} if projects.empty?
 
       start_time = @contributor_time_instance.years_ago(1).beginning_of_day
       end_time = @contributor_time_instance.end_of_day
 
       date_interval = "INTERVAL '#{@contributor_time_instance.utc_offset} seconds'"
 
+      if Feature.enabled?(:contributions_calendar_refactoring)
+        return contributions_between(start_time, end_time).count_by_dates(date_interval)
+      end
+
+      # TODO: Remove after feature flag `contributions_calendar_refactoring` is rolled out
+      # See https://gitlab.com/gitlab-org/gitlab/-/issues/429648
+      # rubocop: disable CodeReuse/ActiveRecord -- will be removed
       # Can't use Event.contributions here because we need to check 3 different
-      # project_features for the (currently) 3 different contribution types
+      # project_features for the (currently) 4 different contribution types
       repo_events = events_created_between(start_time, end_time, :repository)
         .where(action: :pushed)
       issue_events = events_created_between(start_time, end_time, :issues)
@@ -42,55 +48,110 @@ def activity_dates
         .from_union([repo_events, issue_events, mr_events, note_events], remove_duplicates: false)
         .group(:date)
         .map(&:attributes)
+      # rubocop: enable CodeReuse/ActiveRecord
 
-      @activity_dates = events.each_with_object(Hash.new { |h, k| h[k] = 0 }) do |event, activities|
+      events.each_with_object(Hash.new { |h, k| h[k] = 0 }) do |event, activities|
         activities[event["date"]] += event["num_events"]
       end
     end
-    # rubocop: enable CodeReuse/ActiveRecord
 
-    # rubocop: disable CodeReuse/ActiveRecord
     def events_by_date(date)
       return Event.none unless can_read_cross_project?
 
       date_in_time_zone = date.in_time_zone(@contributor_time_instance.time_zone)
 
+      if Feature.enabled?(:contributions_calendar_refactoring)
+        return contributions_between(date_in_time_zone.beginning_of_day, date_in_time_zone.end_of_day).with_associations
+      end
+
+      # TODO: Remove after feature flag `contributions_calendar_refactoring` is rolled out
+      # See https://gitlab.com/gitlab-org/gitlab/-/issues/429648
+      # rubocop: disable CodeReuse/ActiveRecord -- will be removed
       Event.contributions.where(author_id: contributor.id)
         .where(created_at: date_in_time_zone.beginning_of_day..date_in_time_zone.end_of_day)
         .where(project_id: projects)
         .with_associations
+      # rubocop: enable CodeReuse/ActiveRecord
     end
-    # rubocop: enable CodeReuse/ActiveRecord
 
-    def starting_year
-      @contributor_time_instance.years_ago(1).year
-    end
+    private
 
-    def starting_month
-      @contributor_time_instance.month
+    def contributions_between(start_time, end_time)
+      # Can't use Event.contributions here because we need to check 3 different
+      # project_features for the (currently) 4 different contribution types
+      repo_events =
+        project_events_created_between(start_time, end_time, features: :repository)
+          .for_action(:pushed)
+
+      issue_events =
+        project_events_created_between(start_time, end_time, features: :issues)
+          .for_issue
+          .for_action(%i[created closed])
+
+      mr_events =
+        project_events_created_between(start_time, end_time, features: :merge_requests)
+          .for_merge_request
+          .for_action(%i[merged created closed approved])
+
+      note_events =
+        project_events_created_between(start_time, end_time, features: %i[issues merge_requests])
+          .for_action(:commented)
+
+      Event.from_union([repo_events, issue_events, mr_events, note_events], remove_duplicates: false)
     end
 
-    private
-
     def can_read_cross_project?
       Ability.allowed?(current_user, :read_cross_project)
     end
 
-    # rubocop: disable CodeReuse/ActiveRecord
-    def events_created_between(start_time, end_time, feature)
+    # rubocop: disable CodeReuse/ActiveRecord -- no need to move this to ActiveRecord model
+    def project_events_created_between(start_time, end_time, features:)
+      Array(features).reduce(Event.none) do |events, feature|
+        events.or(contribution_events(start_time, end_time).where(project_id: authed_projects(feature)))
+      end
+    end
+    # rubocop: enable CodeReuse/ActiveRecord
+
+    def authed_projects(feature)
+      strong_memoize("#{feature}_projects") do
+        # no need to check features access of current user, if the contributor opted-in
+        # to show all private events anyway - otherwise they would get filtered out again
+        next contributed_project_ids if contributor.include_private_contributions?
+
+        # rubocop: disable CodeReuse/ActiveRecord -- no need to move this to ActiveRecord model
+        ProjectFeature
+          .with_feature_available_for_user(feature, current_user)
+          .where(project_id: contributed_project_ids)
+          .pluck(:project_id)
+        # rubocop: enable CodeReuse/ActiveRecord
+      end
+    end
+
+    # rubocop: disable CodeReuse/ActiveRecord -- no need to move this to ActiveRecord model
+    def contributed_project_ids
       # re-running the contributed projects query in each union is expensive, so
       # use IN(project_ids...) instead. It's the intersection of two users so
       # the list will be (relatively) short
       @contributed_project_ids ||= projects.distinct.pluck(:id)
+    end
+    # rubocop: enable CodeReuse/ActiveRecord
 
+    def contribution_events(start_time, end_time)
+      contributor.events.created_between(start_time, end_time)
+    end
+
+    # TODO: Remove after feature flag `contributions_calendar_refactoring` is rolled out
+    # See https://gitlab.com/gitlab-org/gitlab/-/issues/429648
+    # rubocop: disable CodeReuse/ActiveRecord -- will be removed
+    def events_created_between(start_time, end_time, feature)
       # no need to check feature access of current user, if the contributor opted-in
       # to show all private events anyway - otherwise they would get filtered out again
-      authed_projects = if @contributor.include_private_contributions?
-                          @contributed_project_ids
+      authed_projects = if contributor.include_private_contributions?
+                          contributed_project_ids
                         else
                           ProjectFeature
                             .with_feature_available_for_user(feature, current_user)
-                            .where(project_id: @contributed_project_ids)
+                            .where(project_id: contributed_project_ids)
                             .reorder(nil)
                             .select(:project_id)
                         end
diff --git a/spec/lib/gitlab/contributions_calendar_spec.rb b/spec/lib/gitlab/contributions_calendar_spec.rb
index 326e27fa716d4..732b7cb3e5937 100644
--- a/spec/lib/gitlab/contributions_calendar_spec.rb
+++ b/spec/lib/gitlab/contributions_calendar_spec.rb
@@ -19,9 +19,9 @@
     end
   end
 
-  let_it_be(:feature_project) do
+  let_it_be(:public_project_with_private_issues) do
     create(:project, :public, :issues_private) do |project|
-      create(:project_member, user: contributor, project: project).project
+      create(:project_member, user: contributor, project: project)
     end
   end
 
@@ -45,7 +45,12 @@ def calendar(current_user = nil)
   end
 
   def create_event(project, day, hour = 0, action = :created, target_symbol = :issue)
-    targets[project] ||= create(target_symbol, project: project, author: contributor)
+    targets[project] ||=
+      if target_symbol == :merge_request
+        create(:merge_request, source_project: project, author: contributor)
+      else
+        create(target_symbol, project: project, author: contributor)
+      end
 
     Event.create!(
       project: project,
@@ -58,49 +63,65 @@ def create_event(project, day, hour = 0, action = :created, target_symbol = :iss
   end
 
   describe '#activity_dates', :aggregate_failures do
-    it "returns a hash of date => count" do
-      create_event(public_project, last_week)
-      create_event(public_project, last_week)
-      create_event(public_project, today)
-      work_item_event = create_event(private_project, today, 0, :created, :work_item)
+    shared_examples_for 'returns a hash of date => count' do
+      specify do
+        create_event(public_project, last_week)
+        create_event(public_project, last_week)
+        create_event(public_project, today)
+        work_item_event = create_event(private_project, today, 0, :created, :work_item)
 
-      # make sure the target is a work item as we want to include those in the count
-      expect(work_item_event.target_type).to eq('WorkItem')
-      expect(calendar(contributor).activity_dates).to eq(last_week => 2, today => 2)
+        # make sure the target is a work item as we want to include those in the count
+        expect(work_item_event.target_type).to eq('WorkItem')
+        expect(calendar(contributor).activity_dates).to eq(last_week => 2, today => 2)
+      end
     end
 
-    context "when the user has opted-in for private contributions" do
-      before do
-        contributor.update_column(:include_private_contributions, true)
-      end
+    shared_examples 'private contribution events' do
+      context "when the user has opted-in for private contributions" do
+        before do
+          contributor.update_column(:include_private_contributions, true)
+        end
 
-      it "shows private and public events to all users" do
-        create_event(private_project, today)
-        create_event(public_project, today)
+        it "shows private and public events to all users" do
+          create_event(private_project, today)
+          create_event(public_project, today)
 
-        expect(calendar.activity_dates[today]).to eq(2)
-        expect(calendar(user).activity_dates[today]).to eq(2)
-        expect(calendar(contributor).activity_dates[today]).to eq(2)
-      end
+          expect(calendar.activity_dates[today]).to eq(2)
+          expect(calendar(user).activity_dates[today]).to eq(2)
+          expect(calendar(contributor).activity_dates[today]).to eq(2)
+        end
+
+        # tests for bug https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74826
+        it "still counts correct with feature access levels set to private" do
+          create_event(private_project, today)
 
-      # tests for bug https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74826
-      it "still counts correct with feature access levels set to private" do
-        create_event(private_project, today)
+          private_project.project_feature.update_attribute(:issues_access_level, ProjectFeature::PRIVATE)
+          private_project.project_feature.update_attribute(:repository_access_level, ProjectFeature::PRIVATE)
+          private_project.project_feature.update_attribute(:merge_requests_access_level, ProjectFeature::PRIVATE)
 
-        private_project.project_feature.update_attribute(:issues_access_level, ProjectFeature::PRIVATE)
-        private_project.project_feature.update_attribute(:repository_access_level, ProjectFeature::PRIVATE)
-        private_project.project_feature.update_attribute(:merge_requests_access_level, ProjectFeature::PRIVATE)
+          expect(calendar.activity_dates[today]).to eq(1)
+          expect(calendar(user).activity_dates[today]).to eq(1)
+          expect(calendar(contributor).activity_dates[today]).to eq(1)
+        end
 
-        expect(calendar.activity_dates[today]).to eq(1)
-        expect(calendar(user).activity_dates[today]).to eq(1)
-        expect(calendar(contributor).activity_dates[today]).to eq(1)
+        it "does not fail if there are no contributed projects" do
+          expect(calendar.activity_dates[today]).to eq(nil)
+        end
       end
+    end
 
-      it "does not fail if there are no contributed projects" do
-        expect(calendar.activity_dates[today]).to eq(nil)
+    context 'when contributions_calendar_refactoring feature flag is disabled' do
+      before do
+        stub_feature_flags(contributions_calendar_refactoring: false)
       end
+
+      it_behaves_like 'returns a hash of date => count'
+      include_examples 'private contribution events'
     end
 
+    it_behaves_like 'returns a hash of date => count'
+    include_examples 'private contribution events'
+
     it "counts the diff notes on merge request" do
       create_event(public_project, today, 0, :commented, :diff_note_on_merge_request)
 
@@ -114,6 +135,15 @@ def create_event(project, day, hour = 0, action = :created, target_symbol = :iss
       expect(calendar(contributor).activity_dates[today]).to eq(2)
     end
 
+    it "counts merge request events" do
+      create_event(public_project, today, 0, :created, :merge_request)
+      create_event(public_project, today, 1, :closed, :merge_request)
+      create_event(public_project, today, 2, :approved, :merge_request)
+      create_event(public_project, today, 3, :merged, :merge_request)
+
+      expect(calendar(contributor).activity_dates[today]).to eq(4)
+    end
+
     context "when events fall under different dates depending on the system time zone" do
       before do
         create_event(public_project, today, 1)
@@ -189,65 +219,56 @@ def create_event(project, day, hour = 0, action = :created, target_symbol = :iss
     it "only shows private events to authorized users" do
       e1 = create_event(public_project, today)
       e2 = create_event(private_project, today)
-      e3 = create_event(feature_project, today)
+      e3 = create_event(public_project_with_private_issues, today, 0, :created, :issue)
       create_event(public_project, last_week)
 
-      expect(calendar.events_by_date(today)).to contain_exactly(e1, e3)
-      expect(calendar(contributor).events_by_date(today)).to contain_exactly(e1, e2, e3)
-    end
-
-    it "includes diff notes on merge request" do
-      e1 = create_event(public_project, today, 0, :commented, :diff_note_on_merge_request)
-
       expect(calendar.events_by_date(today)).to contain_exactly(e1)
+      expect(calendar(contributor).events_by_date(today)).to contain_exactly(e1, e2, e3)
     end
 
-    context 'when the user cannot read cross project' do
+    context 'when contributions_calendar_refactoring feature flag is disabled' do
       before do
-        allow(Ability).to receive(:allowed?).and_call_original
-        expect(Ability).to receive(:allowed?).with(user, :read_cross_project) { false }
+        stub_feature_flags(contributions_calendar_refactoring: false)
       end
 
-      it 'does not return any events' do
-        create_event(public_project, today)
+      it "only shows private events to authorized users" do
+        e1 = create_event(public_project, today)
+        e2 = create_event(private_project, today)
+        e3 = create_event(public_project_with_private_issues, today, 0, :created, :issue)
+        create_event(public_project, last_week)
 
-        expect(calendar(user).events_by_date(today)).to be_empty
+        expect(calendar.events_by_date(today)).to contain_exactly(e1, e3)
+        expect(calendar(contributor).events_by_date(today)).to contain_exactly(e1, e2, e3)
       end
     end
-  end
 
-  describe '#starting_year' do
-    let(:travel_time) { Time.find_zone('UTC').local(2020, 12, 31, 19, 0, 0) }
+    it "includes diff notes on merge request" do
+      e1 = create_event(public_project, today, 0, :commented, :diff_note_on_merge_request)
 
-    context "when the contributor's timezone is not set" do
-      it "is the start of last year in the system timezone" do
-        expect(calendar.starting_year).to eq(2019)
-      end
+      expect(calendar.events_by_date(today)).to contain_exactly(e1)
     end
 
-    context "when the contributor's timezone is set to Sydney" do
-      let(:contributor) { create(:user, { timezone: 'Sydney' }) }
+    it 'includes merge request events' do
+      mr_created_event = create_event(public_project, today, 0, :created, :merge_request)
+      mr_closed_event = create_event(public_project, today, 1, :closed, :merge_request)
+      mr_approved_event = create_event(public_project, today, 2, :approved, :merge_request)
+      mr_merged_event = create_event(public_project, today, 3, :merged, :merge_request)
 
-      it "is the start of last year in Sydney" do
-        expect(calendar.starting_year).to eq(2020)
-      end
+      expect(calendar.events_by_date(today)).to contain_exactly(
+        mr_created_event, mr_closed_event, mr_approved_event, mr_merged_event
+      )
     end
-  end
 
-  describe '#starting_month' do
-    let(:travel_time) { Time.find_zone('UTC').local(2020, 12, 31, 19, 0, 0) }
-
-    context "when the contributor's timezone is not set" do
-      it "is the start of this month in the system timezone" do
-        expect(calendar.starting_month).to eq(12)
+    context 'when the user cannot read cross project' do
+      before do
+        allow(Ability).to receive(:allowed?).and_call_original
+        expect(Ability).to receive(:allowed?).with(user, :read_cross_project) { false }
       end
-    end
 
-    context "when the contributor's timezone is set to Sydney" do
-      let(:contributor) { create(:user, { timezone: 'Sydney' }) }
+      it 'does not return any events' do
+        create_event(public_project, today)
 
-      it "is the start of this month in Sydney" do
-        expect(calendar.starting_month).to eq(1)
+        expect(calendar(user).events_by_date(today)).to be_empty
       end
     end
   end
diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb
index 3e4fe57c59b14..0cae348855038 100644
--- a/spec/models/event_spec.rb
+++ b/spec/models/event_spec.rb
@@ -115,6 +115,18 @@
       end
     end
 
+    describe '.for_merge_request' do
+      let(:mr_event) { create(:event, :for_merge_request, project: project) }
+
+      before do
+        create(:event, :for_issue, project: project)
+      end
+
+      it 'returns events for MergeRequest target_type' do
+        expect(described_class.for_merge_request).to contain_exactly(mr_event)
+      end
+    end
+
     describe '.created_at' do
       it 'can find the right event' do
         time = 1.day.ago
@@ -128,6 +140,21 @@
       end
     end
 
+    describe '.created_between' do
+      it 'returns events created between given timestamps' do
+        start_time = 2.days.ago
+        end_time = Date.today
+
+        create(:event, created_at: 3.days.ago)
+        e1 = create(:event, created_at: 2.days.ago)
+        e2 = create(:event, created_at: 1.day.ago)
+
+        found = described_class.created_between(start_time, end_time)
+
+        expect(found).to contain_exactly(e1, e2)
+      end
+    end
+
     describe '.for_fingerprint' do
       let_it_be(:with_fingerprint) { create(:event, fingerprint: 'aaa', project: project) }
 
@@ -152,16 +179,28 @@
     end
 
     describe '.contributions' do
-      let!(:merge_request_event) { create(:event, :created, :for_merge_request, project: project) }
-      let!(:issue_event) { create(:event, :created, :for_issue, project: project) }
+      let!(:merge_request_events) do
+        %i[created closed merged approved].map do |action|
+          create(:event, :for_merge_request, action: action, project: project)
+        end
+      end
+
       let!(:work_item_event) { create(:event, :created, :for_work_item, project: project) }
-      let!(:design_event) { create(:design_event, project: project) }
+      let!(:issue_events) do
+        %i[created closed].map { |action| create(:event, :for_issue, action: action, project: project) }
+      end
+
+      let!(:push_event) { create_push_event(project, project.owner) }
+      let!(:comment_event) { create(:event, :commented, project: project) }
+
+      before do
+        create(:design_event, project: project) # should not be in scope
+      end
 
-      it 'returns events for MergeRequest, Issue and WorkItem' do
+      it 'returns events for MergeRequest, Issue, WorkItem and push, comment events' do
         expect(described_class.contributions).to contain_exactly(
-          merge_request_event,
-          issue_event,
-          work_item_event
+          *merge_request_events, *issue_events, work_item_event,
+          push_event, comment_event
         )
       end
     end
-- 
GitLab