From 2cf11abe1e1f1c5d673478030645da33f60e0db0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me>
Date: Mon, 19 Feb 2024 13:33:47 +0100
Subject: [PATCH] Improved Milestones and add a test for
 Keeps::DeleteOldFeatureFlags
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Signed-off-by: Rémy Coutable <remy@rymai.me>
---
 Guardfile                                   |   3 +-
 keeps/delete_old_feature_flags.rb           | 102 +++++++++++---------
 keeps/helpers/groups.rb                     |   3 +-
 keeps/helpers/milestones.rb                 |  34 +++++--
 spec/keeps/delete_old_feature_flags_spec.rb |  74 ++++++++++++++
 spec/keeps/helpers/milestones_spec.rb       |  98 ++++++++++++++++++-
 6 files changed, 252 insertions(+), 62 deletions(-)
 create mode 100644 spec/keeps/delete_old_feature_flags_spec.rb

diff --git a/Guardfile b/Guardfile
index cd4486db404b9..33990778c1ba7 100644
--- a/Guardfile
+++ b/Guardfile
@@ -6,7 +6,7 @@ require "guard/rspec/dsl"
 
 cmd = ENV['GUARD_CMD'] || (ENV['SPRING'] ? 'spring rspec' : 'bundle exec rspec')
 
-directories %w[app ee lib rubocop tooling spec]
+directories %w[app ee keeps lib rubocop tooling spec]
 
 rspec_context_for = proc do |context_path|
   OpenStruct.new(to_s: "spec").tap do |rspec| # rubocop:disable Style/OpenStructUse
@@ -41,6 +41,7 @@ guard_setup = proc do |context_path|
   watch(rspec.spec_files)
 
   # Ruby files
+  watch(%r{^#{context_path}(keeps/.+)\.rb$}) { |m| rspec.spec.call(m[1]) }
   watch(%r{^#{context_path}(lib/.+)\.rb$}) { |m| rspec.spec.call(m[1]) }
   watch(%r{^#{context_path}(rubocop/.+)\.rb$}) { |m| rspec.spec.call(m[1]) }
   watch(%r{^#{context_path}(tooling/.+)\.rb$}) { |m| rspec.spec.call(m[1]) }
diff --git a/keeps/delete_old_feature_flags.rb b/keeps/delete_old_feature_flags.rb
index 53b06cd8f7944..952245dc48fdd 100644
--- a/keeps/delete_old_feature_flags.rb
+++ b/keeps/delete_old_feature_flags.rb
@@ -4,8 +4,8 @@
 require 'cgi'
 
 require_relative '../config/environment'
-require_relative './helpers/groups'
-require_relative './helpers/milestones'
+require_relative 'helpers/groups'
+require_relative 'helpers/milestones'
 
 module Keeps
   # This is an implementation of a ::Gitlab::Housekeeper::Keep. This keep will locate any featrure flag definition file
@@ -28,70 +28,76 @@ class DeleteOldFeatureFlags < ::Gitlab::Housekeeper::Keep
     FEATURE_FLAG_LOG_ISSUES_URL = "https://gitlab.com/gitlab-com/gl-infra/feature-flag-log/-/issues/?search=%<feature_flag_name>s&sort=created_date&state=all&label_name%%5B%%5D=host%%3A%%3Agitlab.com"
 
     def each_change
-      each_feature_flag do |feature_flag_yaml_file, feature_flag_definition|
-        feature_flag = Feature::Definition.new(feature_flag_yaml_file, feature_flag_definition)
+      each_feature_flag do |feature_flag|
+        change = prepare_change(feature_flag)
 
-        if feature_flag.milestone.nil?
-          puts "#{feature_flag.name} has no milestone set!"
-          next
-        end
+        yield(change) if change
+      end
+    end
 
-        next unless milestones_helper.before_cuttoff?(
-          milestone: feature_flag.milestone,
-          milestones_ago: CUTOFF_MILESTONE_OLD)
+    private
 
-        change = ::Gitlab::Housekeeper::Change.new
-        change.changelog_type = 'removed'
-        change.title = "Delete the `#{feature_flag.name}` feature flag"
-        change.identifiers = [self.class.name.demodulize, feature_flag.name]
+    def prepare_change(feature_flag)
+      if feature_flag.milestone.nil?
+        puts "#{feature_flag.name} has no milestone set!"
+        return
+      end
 
-        # rubocop:disable Gitlab/DocUrl -- Not running inside rails application
-        change.description = <<~MARKDOWN
-        This feature flag was introduced in #{feature_flag.milestone}, which is more than #{CUTOFF_MILESTONE_OLD} milestones ago.
+      return unless milestones_helper.before_cuttoff?(
+        milestone: feature_flag.milestone,
+        milestones_ago: CUTOFF_MILESTONE_OLD)
 
-        As part of our process we want to ensure [feature flags don't stay too long in the codebase](https://docs.gitlab.com/ee/development/feature_flags/#types-of-feature-flags).
+      change = ::Gitlab::Housekeeper::Change.new
+      change.changelog_type = 'removed'
+      change.title = "Delete the `#{feature_flag.name}` feature flag"
+      change.identifiers = [self.class.name.demodulize, feature_flag.name]
 
-        Rollout issue: #{feature_flag_rollout_issue_url(feature_flag)}
+      # rubocop:disable Gitlab/DocUrl -- Not running inside rails application
+      change.description = <<~MARKDOWN
+      This feature flag was introduced in #{feature_flag.milestone}, which is more than #{CUTOFF_MILESTONE_OLD} milestones ago.
 
-        #{feature_flag_default_enabled_note(feature_flag_definition[:default_enabled])}
+      As part of our process we want to ensure [feature flags don't stay too long in the codebase](https://docs.gitlab.com/ee/development/feature_flags/#types-of-feature-flags).
 
-        <details><summary>Mentions of the feature flag (click to expand)</summary>
+      Rollout issue: #{feature_flag_rollout_issue_url(feature_flag)}
 
-        ```
-        #{feature_flag_grep(feature_flag.name)}
-        ```
+      #{feature_flag_default_enabled_note(feature_flag.default_enabled)}
 
-        </details>
+      <details><summary>Mentions of the feature flag (click to expand)</summary>
 
-        It is likely that this MR will still need some changes to remove references to the feature flag in the code.
-        At the moment the `gitlab-housekeeper` is not capable of removing references but we'll be adding that functionality next.
-        It is the responsibility of ~"#{feature_flag.group}" to push those changes to this branch.
-        If they are already removing this feature flag in another merge request then they can just close this merge request.
+      ```
+      #{feature_flag_grep(feature_flag.name)}
+      ```
 
-        You can also see the status of the rollout by checking #{feature_flag_rollout_issue_url(feature_flag)} and #{format(FEATURE_FLAG_LOG_ISSUES_URL, feature_flag_name: feature_flag.name)}.
-        MARKDOWN
-        # rubocop:enable Gitlab/DocUrl
+      </details>
 
-        FileUtils.rm(feature_flag_yaml_file)
+      It is likely that this MR will still need some changes to remove references to the feature flag in the code.
+      At the moment the `gitlab-housekeeper` is not capable of removing references but we'll be adding that functionality next.
+      It is the responsibility of ~"#{feature_flag.group}" to push those changes to this branch.
+      If they are already removing this feature flag in another merge request then they can just close this merge request.
 
-        change.changed_files = [feature_flag_yaml_file]
+      You can also see the status of the rollout by checking #{feature_flag_rollout_issue_url(feature_flag)} and #{format(FEATURE_FLAG_LOG_ISSUES_URL, feature_flag_name: feature_flag.name)}.
+      MARKDOWN
+      # rubocop:enable Gitlab/DocUrl
 
-        change.labels = [
-          'maintenance::removal',
-          'feature flag',
-          feature_flag.group
-        ]
+      FileUtils.rm(feature_flag.path)
 
-        change.reviewers = assignees(feature_flag.rollout_issue_url)
+      change.changed_files = [feature_flag.path]
 
-        if change.reviewers.empty?
-          group_data = groups_helper.group_for_group_label(feature_flag.group)
+      change.labels = [
+        'maintenance::removal',
+        'feature flag',
+        feature_flag.group
+      ]
 
-          change.reviewers = groups_helper.pick_reviewer(group_data, change.identifiers) if group_data
-        end
+      change.reviewers = assignees(feature_flag.rollout_issue_url)
 
-        yield(change)
+      if change.reviewers.empty?
+        group_data = groups_helper.group_for_group_label(feature_flag.group)
+
+        change.reviewers = groups_helper.pick_reviewer(group_data, change.identifiers) if group_data
       end
+
+      change
     end
 
     def feature_flag_default_enabled_note(feature_flag_default_enabled)
@@ -151,7 +157,9 @@ def get_rollout_issue(rollout_issue_url)
 
     def each_feature_flag
       all_feature_flag_files.map do |f|
-        yield(f, YAML.load_file(f, permitted_classes: [Symbol], symbolize_names: true))
+        yield(
+          Feature::Definition.new(f, YAML.load_file(f, permitted_classes: [Symbol], symbolize_names: true))
+        )
       end
     end
 
diff --git a/keeps/helpers/groups.rb b/keeps/helpers/groups.rb
index 362dc9ddd064b..2cbf2bc295757 100644
--- a/keeps/helpers/groups.rb
+++ b/keeps/helpers/groups.rb
@@ -3,6 +3,7 @@
 module Keeps
   module Helpers
     class Groups
+      GROUPS_JSON_URL = "https://about.gitlab.com/groups.json"
       Error = Class.new(StandardError)
 
       def group_for_feature_category(category)
@@ -31,7 +32,7 @@ def groups
 
       def fetch_groups
         @groups_json ||= begin
-          response = Gitlab::HTTP.get('https://about.gitlab.com/groups.json')
+          response = Gitlab::HTTP.get(GROUPS_JSON_URL)
 
           unless (200..299).cover?(response.code)
             raise Error,
diff --git a/keeps/helpers/milestones.rb b/keeps/helpers/milestones.rb
index a6e4397b3c871..a747dde835da2 100644
--- a/keeps/helpers/milestones.rb
+++ b/keeps/helpers/milestones.rb
@@ -3,12 +3,18 @@
 module Keeps
   module Helpers
     class Milestones
+      RELEASES_YML_URL = "https://gitlab.com/gitlab-com/www-gitlab-com/-/blob/master/data/releases.yml"
       Error = Class.new(StandardError)
+      Milestone = Struct.new(:version, :date, keyword_init: true)
 
       def before_cuttoff?(milestone:, milestones_ago:)
         Gem::Version.new(milestone) < Gem::Version.new(past_milestone(milestones_ago: milestones_ago))
       end
 
+      def past_milestone(milestones_ago:)
+        milestones[current_milestone_index + milestones_ago].version
+      end
+
       private
 
       def current_milestone
@@ -16,19 +22,27 @@ def current_milestone
         milestone.gsub(/^(\d+\.\d+).*$/, '\1').chomp
       end
 
-      def past_milestone(milestones_ago:)
-        major, minor = current_milestone.split(".").map(&:to_i)
+      def current_milestone_index
+        milestones.index { |milestone| milestone.version == current_milestone }
+      end
 
-        older_major =
-          if minor >= milestones_ago
-            major
-          else
-            major - (((milestones_ago - minor - 1) / 13) + 1)
-          end
+      def milestones
+        @milestones ||= fetch_milestones.map do |milestone|
+          Milestone.new(**milestone)
+        end
+      end
 
-        older_minor = (0..12).to_a[(minor - milestones_ago) % 13]
+      def fetch_milestones
+        @milestones_yaml ||= begin
+          response = Gitlab::HTTP.get(RELEASES_YML_URL)
+
+          unless (200..299).cover?(response.code)
+            raise Error,
+              "Failed to get group information with response code: #{response.code} and body:\n#{response.body}"
+          end
 
-        [older_major, older_minor].join(".")
+          YAML.safe_load(response.body)
+        end
       end
     end
   end
diff --git a/spec/keeps/delete_old_feature_flags_spec.rb b/spec/keeps/delete_old_feature_flags_spec.rb
new file mode 100644
index 0000000000000..2063d52b8df26
--- /dev/null
+++ b/spec/keeps/delete_old_feature_flags_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require './keeps/delete_old_feature_flags'
+
+RSpec.describe Keeps::DeleteOldFeatureFlags, feature_category: :tooling do
+  let(:groups) do
+    {
+      foo: {
+        label: 'group::foo',
+        backend_engineers: ['@john_doe']
+      }
+    }
+  end
+
+  let(:feature_flag_name) { 'feature_flag_name' }
+  let(:feature_flag_milestone) { '15.8' }
+  let(:feature_flag_file) do
+    Tempfile.new('feature_flag.yml').tap do |file|
+      file.open
+      file.write({
+        name: feature_flag_name,
+        milestone: feature_flag_milestone,
+        group: groups.dig(:foo, :label)
+      }.to_yaml)
+      file.rewind
+    end
+  end
+
+  let(:milestones_helper) { instance_double(Keeps::Helpers::Milestones) }
+
+  subject(:keep) { described_class.new }
+
+  before do
+    stub_request(:get, Keeps::Helpers::Groups::GROUPS_JSON_URL).to_return(status: 200, body: groups.to_json)
+
+    allow(keep).to receive(:all_feature_flag_files).and_return([feature_flag_file.path])
+    allow(keep).to receive(:milestones_helper).and_return(milestones_helper)
+
+    allow(milestones_helper)
+      .to receive(:before_cuttoff?).with(milestone: feature_flag_milestone, milestones_ago: 12)
+      .and_return(true)
+  end
+
+  after do
+    feature_flag_file.close
+  end
+
+  describe '#each_change' do
+    let(:expected_change) { instance_double(Gitlab::Housekeeper::Change) }
+
+    it 'returns a Gitlab::Housekeeper::Change', :aggregate_failures do
+      expect(Gitlab::Housekeeper::Shell).to receive(:execute).with(
+        'git', 'grep', '--heading', '--line-number', '--break',
+        feature_flag_name, '--', ':^locale/', ':^db/structure.sql'
+      )
+
+      expect(FileUtils).to receive(:rm).with(feature_flag_file.path)
+
+      actual_changes = keep.each_change(&:itself)
+
+      expect(actual_changes.size).to eq(1)
+
+      actual_change = actual_changes.first
+      expect(actual_change).to be_a(Gitlab::Housekeeper::Change)
+      expect(actual_change.changelog_type).to eq('removed')
+      expect(actual_change.title).to eq("Delete the `#{feature_flag_name}` feature flag")
+      expect(actual_change.identifiers).to eq([described_class.name.demodulize, feature_flag_name])
+      expect(actual_change.changed_files).to eq([feature_flag_file.path])
+      expect(actual_change.reviewers).to eq(['@john_doe'])
+      expect(actual_change.labels).to eq(['maintenance::removal', 'feature flag', groups.dig(:foo, :label)])
+    end
+  end
+end
diff --git a/spec/keeps/helpers/milestones_spec.rb b/spec/keeps/helpers/milestones_spec.rb
index 8ee2e29769ae9..67c66b347db42 100644
--- a/spec/keeps/helpers/milestones_spec.rb
+++ b/spec/keeps/helpers/milestones_spec.rb
@@ -1,12 +1,102 @@
 # frozen_string_literal: true
 
-require 'fast_spec_helper'
+require 'spec_helper'
 require './keeps/helpers/milestones'
 
 RSpec.describe Keeps::Helpers::Milestones, feature_category: :tooling do
+  let(:milestones_yaml) do
+    <<~YAML
+    - version: '17.0'
+      date: '2024-05-16'
+    - version: '16.11'
+      date: '2024-04-18'
+    - version: '16.10'
+      date: '2024-03-21'
+    - version: '16.9'
+      date: '2024-02-15'
+    - version: '16.8'
+      date: '2024-01-18'
+    - version: '16.7'
+      date: '2023-12-21'
+    - version: '16.6'
+      date: '2023-11-16'
+    - version: '16.5'
+      date: '2023-10-22'
+    - version: '16.4'
+      date: '2023-09-22'
+    - version: '16.3'
+      date: '2023-08-22'
+    - version: '16.2'
+      date: '2023-07-22'
+    - version: '16.1'
+      date: '2023-06-22'
+    - version: '16.0'
+      date: '2023-05-22'
+    - version: '15.11'
+      date: '2023-04-22'
+    - version: '15.10'
+      date: '2023-03-22'
+    - version: '15.9'
+      date: '2023-02-22'
+    - version: '15.8'
+      date: '2023-01-22'
+    - version: '15.7'
+      date: '2022-12-22'
+    - version: '15.6'
+      date: '2022-11-22'
+    - version: '15.5'
+      date: '2022-10-22'
+    - version: '15.4'
+      date: '2022-09-22'
+    - version: '15.3'
+      date: '2022-08-22'
+    - version: '15.2'
+      date: '2022-07-22'
+    - version: '15.1'
+      date: '2022-06-22'
+    - version: '15.0'
+      date: '2022-05-22'
+    - version: '14.10'
+      date: '2022-04-22'
+    - version: '14.9'
+      date: '2022-03-22'
+    YAML
+  end
+
   before do
     allow(File).to receive(:read).and_call_original
     allow(File).to receive(:read).with(File.expand_path('../../../VERSION', __dir__)).and_return('16.9.0-pre')
+    stub_request(:get, described_class::RELEASES_YML_URL).to_return(status: 200, body: milestones_yaml)
+  end
+
+  describe '#past_milestone' do
+    let(:milestone) { '16.9' }
+
+    subject(:past_milestone) { described_class.new.past_milestone(milestones_ago: milestones_ago) }
+
+    context 'when asking 1 milestone ago' do
+      let(:milestones_ago) { 1 }
+
+      it { is_expected.to eq('16.8') }
+    end
+
+    context 'when asking 9 milestones ago' do
+      let(:milestones_ago) { 9 }
+
+      it { is_expected.to eq('16.0') }
+    end
+
+    context 'when asking 10 milestones ago' do
+      let(:milestones_ago) { 10 }
+
+      it { is_expected.to eq('15.11') }
+    end
+
+    context 'when asking 22 milestones ago' do
+      let(:milestones_ago) { 22 }
+
+      it { is_expected.to eq('14.10') }
+    end
   end
 
   describe '#before_cuttoff?' do
@@ -17,13 +107,15 @@
     it { is_expected.to eq(false) }
 
     context 'when milestone is before cuttoff' do
-      let(:milestone) { '15.9' }
+      let(:milestone) { '15.8' }
 
       it { is_expected.to eq(true) }
     end
 
     context 'when milestone is more than 2 major versions before cuttoff' do
-      let(:milestone) { '14.12' }
+      let(:milestone) { '14.10' }
+
+      subject(:before_cuttoff) { described_class.new.before_cuttoff?(milestone: milestone, milestones_ago: 21) }
 
       it { is_expected.to eq(true) }
     end
-- 
GitLab