Files
gitlab-ce/keeps/delete_old_feature_flags.rb
2025-07-17 06:11:58 +00:00

463 lines
16 KiB
Ruby

# frozen_string_literal: true
require 'fileutils'
require 'cgi'
require_relative '../config/environment'
require_relative 'helpers/groups'
require_relative 'helpers/milestones'
require_relative 'helpers/git_diff_parser'
require_relative 'helpers/ai_editor'
require_relative 'prompts/remove_feature_flags'
module Keeps
# This is an implementation of a ::Gitlab::Housekeeper::Keep. This keep will locate any feature flag definition file
# that were added at least `<CUTOFF_MILESTONE_OLD> milestones` ago and remove the definition file.
#
# You can run it individually with:
#
# ```
# bundle exec gitlab-housekeeper -d \
# -k Keeps::DeleteOldFeatureFlags
# ```
class DeleteOldFeatureFlags < ::Gitlab::Housekeeper::Keep
CUTOFF_MILESTONE_FOR_DISABLED_FLAG = 12
CUTOFF_MILESTONE_FOR_ENABLED_FLAG = 4
Error = Class.new(StandardError)
GREP_IGNORE = [
'locale/',
'db/structure.sql'
].freeze
ROLLOUT_ISSUE_URL_REGEX = %r{\Ahttps://gitlab\.com/(?<project_path>.*)/-/issues/(?<issue_iid>\d+)\z}
API_ISSUE_URL = "https://gitlab.com/api/v4/projects/%<project_path>s/issues/%<issue_iid>s"
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"
MISSING_URL_PLACEHOLDER = '(missing URL)'
def each_change
each_feature_flag do |feature_flag|
change = prepare_change(feature_flag)
yield(change) if change
end
end
private
def parse_date(date_string)
Date.parse(date_string)
rescue Date::Error
nil
end
def can_remove_ff?(feature_flag, identifiers, latest_feature_flag_status)
return false unless valid_feature_flag_status?(feature_flag, latest_feature_flag_status)
# Check if feature flag has ready for removal label - this bypasses most validation checks
if has_ready_for_removal_label?(feature_flag)
@logger.puts "#{feature_flag.name} has 'feature flag::ready for removal' label, bypassing validation checks"
return true
end
return false unless valid_rollout_date?(feature_flag)
return false unless valid_milestone?(feature_flag)
return false unless within_milestone_cutoff?(feature_flag, latest_feature_flag_status)
return false unless matches_filter_identifiers?(identifiers)
true
end
def valid_rollout_date?(feature_flag)
intended_to_rollout_by_date = feature_flag.intended_to_rollout_by
return true unless intended_to_rollout_by_date.present?
rollout_date = parse_date(intended_to_rollout_by_date)
if rollout_date.nil?
message = "#{feature_flag.name} intended_to_rollout_by #{intended_to_rollout_by_date}"
@logger.puts "#{message}, is ignored as it cannot be parsed."
elsif rollout_date.future?
@logger.puts "#{feature_flag.name} cannot be removed, intended rollout date is #{intended_to_rollout_by_date}"
return false
end
true
end
def valid_milestone?(feature_flag)
return true unless feature_flag.milestone.nil?
@logger.puts "#{feature_flag.name} has no milestone set!"
false
end
def valid_feature_flag_status?(feature_flag, latest_feature_flag_status)
if latest_feature_flag_status.nil?
@logger.puts "#{feature_flag.name} cannot be removed as we cannot get the status from the rollout issue."
return false
end
if latest_feature_flag_status == :conditional
@logger.puts "#{feature_flag.name} cannot be removed as it is partially rolled out."
return false
end
true
end
def within_milestone_cutoff?(feature_flag, latest_feature_flag_status)
cutoff = if latest_feature_flag_status == :disabled
CUTOFF_MILESTONE_FOR_DISABLED_FLAG
else
CUTOFF_MILESTONE_FOR_ENABLED_FLAG
end
return true if milestones_helper.before_cuttoff?(milestone: feature_flag.milestone, milestones_ago: cutoff)
@logger.puts "#{feature_flag.name} cannot be removed as it is after the cutoff."
false
end
# rubocop:disable Gitlab/DocumentationLinks/HardcodedUrl -- Not running inside rails application
def build_description(feature_flag, latest_feature_flag_status)
ready_for_removal = has_ready_for_removal_label?(feature_flag)
introduction_text = if ready_for_removal
"This feature flag was introduced in #{feature_flag.milestone} and has been " \
"marked as ready for removal with the `~\"feature flag::ready for removal\"` " \
"label, bypassing the standard milestone cutoff requirements."
else
cutoff_count = if latest_feature_flag_status == :enabled
CUTOFF_MILESTONE_FOR_ENABLED_FLAG
else
CUTOFF_MILESTONE_FOR_DISABLED_FLAG
end
"This feature flag was introduced in #{feature_flag.milestone}, which is " \
"more than #{cutoff_count} milestones ago."
end
<<~MARKDOWN
#{introduction_text}
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).
Rollout issue: #{feature_flag_rollout_issue_url(feature_flag)}
<details><summary>Remaining mentions of the feature flag (click to expand)</summary>
```
#{feature_flag_grep(feature_flag.name)}
```
</details>
**Currently the feature flag is `#{latest_feature_flag_status}` on production**
It is possible 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 always capable of removing all references so you must check the diff and pipeline failures to confirm if there are any issues.
It is the responsibility of ~"#{feature_flag.group}" to push those changes to this branch.
**Note:** If you do not want to remove this feature flag at this time, you can add an `intended_to_rollout_by_date` attribute in the feature flag YAML file to prevent automated removal.
## TODO for the reviewers before merging this MR
- [ ] See the status of the rollout by checking #{feature_flag_rollout_issue_url(feature_flag)}, #{format(FEATURE_FLAG_LOG_ISSUES_URL, feature_flag_name: feature_flag.name)}
- [ ] Verify the feature flag status via chatops by running `/chatops run feature get #{feature_flag.name}`.
- [ ] [Search for references to `#{feature_flag.name.split('_').map(&:capitalize).join}` in frontend part of code](https://gitlab.com/search?project_id=278964&scope=blobs&search=#{feature_flag.name.split('_').map(&:capitalize).join}&regex=false)
- [ ] [Search for references to `#{feature_flag.name}` in code](https://gitlab.com/search?project_id=278964&scope=blobs&search=#{feature_flag.name}&regex=false)
- [ ] Check if we need to remove any Gem or other related code by looking at the changes in #{feature_flag.introduced_by_url}
MARKDOWN
end
# rubocop:enable Gitlab/DocumentationLinks/HardcodedUrl
def prepare_change(feature_flag)
identifiers = [self.class.name.demodulize, feature_flag.name]
latest_feature_flag_status = get_latest_feature_flag_status(feature_flag)
return unless can_remove_ff?(feature_flag, identifiers, latest_feature_flag_status)
change = ::Gitlab::Housekeeper::Change.new
change.changelog_type = 'removed'
change.title = "Delete the `#{feature_flag.name}` feature flag"
change.identifiers = identifiers
change.description = build_description(feature_flag, latest_feature_flag_status)
FileUtils.rm(feature_flag.path)
change.changed_files = [feature_flag.path]
applied = apply_patch_or_ask_ai(feature_flag, change)
unless applied
@logger.puts "#{feature_flag.name} aborting because change not applied."
@logger.puts "Change #{change.to_json}"
change.abort!
return change
end
change.labels = [
'automation:feature-flag-removal',
'maintenance::removal',
'feature flag',
feature_flag.group
]
change.reviewers = assignees(feature_flag.rollout_issue_url)
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_grep(feature_flag_name)
all_results = ''
git_patterns(feature_flag_name).each do |pattern|
result = execute_grep(pattern)
all_results += "#{result}\n" if result.present?
end
all_results.empty? ? nil : all_results
end
def execute_grep(pattern)
Gitlab::Housekeeper::Shell.execute(
'git',
'grep',
'--heading',
'--line-number',
'--break',
pattern,
'--',
*(GREP_IGNORE.map { |path| ":^#{path}" })
)
rescue ::Gitlab::Housekeeper::Shell::Error
# git grep returns error status if nothing is found
""
end
def ai_patch(feature_flag, change)
failed_files = []
files_mentioning_feature_flag(feature_flag.name).each do |file|
flag_enabled = get_latest_feature_flag_status(feature_flag) == :enabled
user_message = remove_feature_flag_prompts.fetch(feature_flag, file, flag_enabled)
unless user_message
@logger.puts "#{feature_flag.name}: No prompt generated for #{file}, skipping"
next
end
applied = ai_helper.ask_for_and_apply_patch(user_message, file)
unless applied
@logger.puts "#{feature_flag.name}: Failed to apply AI patch for #{file}, skipping"
failed_files << file
next
end
begin
::Gitlab::Housekeeper::Shell.rubocop_autocorrect(file) unless file.end_with?('.vue', '.js')
rescue ::Gitlab::Housekeeper::Shell::Error => e
@logger.puts "#{feature_flag.name}: Rubocop error for #{file}, but continuing: #{e.message}"
end
change.changed_files << file
end
if failed_files.any?
@logger.puts "failed on #{failed_files.size} files"
@logger.puts "Failed files: #{failed_files.join(', ')}"
end
failed_files.empty?
end
def apply_patch_or_ask_ai(feature_flag, change)
return apply_patch(feature_flag, change) if patch_exists?(feature_flag)
ai_patch(feature_flag, change)
end
def apply_patch(feature_flag, change)
change.changed_files << patch_path(feature_flag)
change.changed_files += extract_changed_files_from_patch(feature_flag)
begin
Gitlab::Housekeeper::Shell.execute('git', 'apply', patch_path(feature_flag))
rescue ::Gitlab::Housekeeper::Shell::Error
@logger.puts "#{patch_path(feature_flag)} git apply error"
return false
end
FileUtils.rm(patch_path(feature_flag))
true
end
def patch_exists?(feature_flag)
File.file?(patch_path(feature_flag))
end
def patch_path(feature_flag)
feature_flag.path.sub(/.yml$/, '.patch')
end
def extract_changed_files_from_patch(feature_flag)
git_diff_parser_helper.all_changed_files(File.read(patch_path(feature_flag)))
end
def feature_flag_rollout_issue_url(feature_flag)
feature_flag.rollout_issue_url || MISSING_URL_PLACEHOLDER
end
def assignees(rollout_issue_url)
rollout_issue = get_rollout_issue(rollout_issue_url)
return unless rollout_issue && rollout_issue[:assignees]
rollout_issue[:assignees].pluck(:username) # rubocop:disable CodeReuse/ActiveRecord -- We need to collect the usernames from array of hashes.
end
def get_rollout_issue(rollout_issue_url)
matches = ROLLOUT_ISSUE_URL_REGEX.match(rollout_issue_url)
return unless matches
# rubocop:disable Gitlab/HttpV2 -- Not running inside rails application
response = Gitlab::HTTP_V2.try_get(
format(API_ISSUE_URL, project_path: CGI.escape(matches[:project_path]), issue_iid: matches[:issue_iid])
)
# rubocop:enable Gitlab/HttpV2
unless response.success?
@logger.puts(
"Get URL: #{rollout_issue_url} Failed with response code: #{response.code} and body:\n#{response.body}"
)
return
end
Gitlab::Json.parse(response.body, symbolize_names: true)
end
def has_ready_for_removal_label?(feature_flag)
rollout_issue_url = feature_flag_rollout_issue_url(feature_flag)
return false if rollout_issue_url == MISSING_URL_PLACEHOLDER
rollout_issue = get_rollout_issue(rollout_issue_url)
return false unless rollout_issue
rollout_issue[:labels].any?('feature flag::ready for removal')
rescue StandardError => e
@logger.puts "Error checking ready for removal label for #{feature_flag.name}: #{e.message}"
false
end
def get_latest_feature_flag_status(feature_flag)
return :enabled if feature_flag.default_enabled
rollout_issue_url = feature_flag_rollout_issue_url(feature_flag)
if rollout_issue_url == MISSING_URL_PLACEHOLDER
@logger.puts "Can't fetch ff status for #{feature_flag.name} due to absence of rollout issue."
return
end
rollout_issue = get_rollout_issue(rollout_issue_url)
return unless rollout_issue
state_label = rollout_issue[:labels].find { |label| label.start_with?('feature flag state::') }
if state_label.nil?
@logger.puts(
"Can't fetch ff status for #{feature_flag.name} due to absence of feature flag state label on rollout issue."
)
return
end
case state_label
when 'feature flag state::enabled'
:enabled
when 'feature flag state::disabled'
:disabled
when 'feature flag state::rolling out'
:conditional
end
end
def each_feature_flag
feature_definitions = all_feature_flag_files.map do |f|
Feature::Definition.new(f,
YAML.safe_load_file(f, permitted_classes: [Symbol], symbolize_names: true))
end
feature_definitions.reject { |f| f.milestone.nil? }.sort_by { |f| Gem::Version.new(f.milestone) }.each do |f|
yield(f)
end
end
def all_feature_flag_files
Dir.glob("{,ee/}config/feature_flags/{development,gitlab_com_derisk,beta}/*.yml")
end
def remove_feature_flag_prompts
@remove_feature_flag_prompts ||= ::Keeps::Prompts::RemoveFeatureFlags.new(@logger)
end
def groups_helper
@groups_helper ||= ::Keeps::Helpers::Groups.new
end
def milestones_helper
@milestones_helper ||= ::Keeps::Helpers::Milestones.new
end
def git_diff_parser_helper
@git_diff_parser_helper ||= ::Keeps::Helpers::GitDiffParser.new
end
def ai_helper
::Keeps::Helpers::AiEditor.new
end
def git_patterns(feature_flag_name)
camel_case_flag = feature_flag_name.camelize(:lower)
[
"feature.*#{feature_flag_name}",
"push_frontend_feature_flag.*#{feature_flag_name}",
"glFeatures.*#{camel_case_flag}",
"gon.*#{camel_case_flag}",
camel_case_flag,
feature_flag_name
]
end
def files_mentioning_feature_flag(feature_flag_name)
all_files = []
git_patterns(feature_flag_name).each do |pattern|
result = find_files_with_pattern(pattern)
all_files += result if result.any?
end
@logger.puts "All files mentioning feature flag #{feature_flag_name}"
all_files.uniq
end
def find_files_with_pattern(pattern)
result = Gitlab::Housekeeper::Shell.execute(
'git',
'grep',
'--name-only',
pattern,
'--',
*(GREP_IGNORE.map { |path| ":^#{path}" })
)
return [] if result.blank?
result.split("\n")
rescue ::Gitlab::Housekeeper::Shell::Error
@logger.puts "No files found for pattern: #{pattern}" if @logger
[]
end
end
end