diff --git a/danger/plugins/rubocop.rb b/danger/plugins/rubocop.rb
index 6645a37041beab0c808028a49ce10e908f5edcc2..d1f63f356440e60fc0907da1200128658a45cb80 100644
--- a/danger/plugins/rubocop.rb
+++ b/danger/plugins/rubocop.rb
@@ -1,12 +1,11 @@
 # frozen_string_literal: true
 
 require_relative '../../tooling/danger/rubocop_inline_disable_suggestion'
+require_relative '../../tooling/danger/rubocop_discourage_todo_addition'
+require_relative '../../tooling/danger/rubocop_helper'
 
 module Danger
   class Rubocop < ::Danger::Plugin
-    # Put the helper code somewhere it can be tested
-    def add_suggestions_for(filename)
-      Tooling::Danger::RubocopInlineDisableSuggestion.new(filename, context: self).suggest
-    end
+    include Tooling::Danger::RubocopHelper
   end
 end
diff --git a/danger/rubocop/Dangerfile b/danger/rubocop/Dangerfile
index b43a1042bd51174e8d0144fd739d5187bc48a158..8c5a408fc9c343886856d3d8e025fdca64f6a171 100644
--- a/danger/rubocop/Dangerfile
+++ b/danger/rubocop/Dangerfile
@@ -1,14 +1,4 @@
 # frozen_string_literal: true
 
-# This is noisy for draft MRs, so let's ignore this cop in draft mode since we have
-# rubocop watching this as well.
-return if helper.draft_mr?
-
-# Danger should not comment when inline disables are added in the following files.
-no_suggestions_for_extensions = %w[.md]
-
-helper.all_changed_files.each do |filename|
-  next if filename.end_with?(*no_suggestions_for_extensions)
-
-  rubocop.add_suggestions_for(filename)
-end
+rubocop.execute_inline_disable_suggestor
+rubocop.execute_todo_suggestor
diff --git a/spec/tooling/danger/rubocop_discourage_todo_addition_spec.rb b/spec/tooling/danger/rubocop_discourage_todo_addition_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1a7f41fb5ee213982df688aabd473e93f729cd35
--- /dev/null
+++ b/spec/tooling/danger/rubocop_discourage_todo_addition_spec.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'gitlab/dangerfiles/spec_helper'
+
+require_relative '../../../tooling/danger/rubocop_discourage_todo_addition'
+require_relative '../../../tooling/danger/project_helper'
+
+RSpec.describe Tooling::Danger::RubocopDiscourageTodoAddition, feature_category: :tooling do
+  include_context "with dangerfile"
+
+  let(:fake_danger) { DangerSpecHelper.fake_danger }
+  let(:fake_project_helper) { instance_double('Tooling::Danger::ProjectHelper') }
+  let(:filename) { '.rubocop_todo/foo.yml' }
+  let(:template) { Tooling::Danger::RubocopDiscourageTodoAddition::SUGGESTION }
+  let(:file_lines) do
+    <<~YML.split("\n")
+      ---
+      RSpec/AvoidConditionalStatements:
+        Exclude:
+          - 'ee/spec/features/admin/admin_settings_spec.rb'
+          - 'ee/spec/features/analytics/code_analytics_spec.rb'
+          - 'ee/spec/features/foo_spec.rb'
+          - 'ee/spec/features/billings/billing_plans_spec.rb'
+          - 'ee/spec/features/boards/scoped_issue_board_spec.rb'
+          - 'ee/spec/features/boards/user_visits_board_spec.rb'
+          - 'ee/spec/features/ci_shared_runner_warnings_spec.rb'
+          - 'ee/spec/features/bar_spec.rb'
+          - 'ee/spec/features/epic_boards/epic_boards_spec.rb'
+    YML
+  end
+
+  let(:changed_lines) do
+    <<~DIFF.split("\n")
+      +    - 'ee/spec/features/foo_spec.rb'
+      +    - 'ee/spec/features/bar_spec.rb'
+    DIFF
+  end
+
+  subject(:rubocop) { fake_danger.new(helper: fake_helper) }
+
+  before do
+    allow(rubocop).to receive(:project_helper).and_return(fake_project_helper)
+    allow(rubocop.helper).to receive(:changed_lines).with(filename).and_return(changed_lines)
+    allow(rubocop.project_helper).to receive(:file_lines).and_return(file_lines)
+
+    rubocop.define_singleton_method(:add_todo_suggestion_for) do |filename|
+      Tooling::Danger::RubocopDiscourageTodoAddition.new(filename, context: self).suggest
+    end
+  end
+
+  it 'adds only one comment in the file' do
+    expect(rubocop).to receive(:markdown).with("\n#{template}".chomp, file: filename, line: 6)
+
+    rubocop.add_todo_suggestion_for(filename)
+  end
+
+  context 'with grace period changes' do
+    let(:file_lines) do
+      <<~YML.split("\n")
+        ---
+        RSpec/AvoidConditionalStatements:
+          Details: grace period
+          Exclude:
+            - 'ee/spec/features/analytics/code_analytics_spec.rb'
+            - 'ee/spec/features/foo_spec.rb'
+      YML
+    end
+
+    let(:basic_diff_lines) do
+      <<~DIFF.split("\n")
+        +    - 'ee/spec/features/foo_spec.rb'
+        +    - 'ee/spec/features/bar_spec.rb'
+      DIFF
+    end
+
+    let(:changed_lines) { basic_diff_lines }
+
+    shared_examples_for 'no_suggestions_for_file' do
+      it 'ignores the file' do
+        expect(rubocop).not_to receive(:markdown)
+
+        rubocop.add_todo_suggestion_for(filename)
+      end
+    end
+
+    context 'when grace period exists and is not part of the change' do
+      it_behaves_like 'no_suggestions_for_file'
+    end
+
+    context 'when grace period exists and is added as part of the change' do
+      let(:changed_lines) { basic_diff_lines.prepend('+  Details: grace period') }
+
+      it_behaves_like 'no_suggestions_for_file'
+    end
+
+    context 'when grace period exists and is removed as part of the change' do
+      let(:file_lines) do
+        <<~YML.split("\n")
+          ---
+          RSpec/AvoidConditionalStatements:
+            Exclude:
+              - 'ee/spec/features/analytics/code_analytics_spec.rb'
+              - 'ee/spec/features/foo_spec.rb'
+        YML
+      end
+
+      let(:changed_lines) { basic_diff_lines.prepend('-  Details: grace period') }
+
+      it_behaves_like 'no_suggestions_for_file'
+    end
+  end
+end
diff --git a/spec/tooling/danger/rubocop_helper_spec.rb b/spec/tooling/danger/rubocop_helper_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..868b2312e2d6007e834fbdd6d37675072d36c0af
--- /dev/null
+++ b/spec/tooling/danger/rubocop_helper_spec.rb
@@ -0,0 +1,107 @@
+# frozen_string_literal: true
+
+require 'rspec-parameterized'
+require 'fast_spec_helper'
+require 'gitlab/dangerfiles/spec_helper'
+require_relative '../../../tooling/danger/rubocop_helper'
+require_relative '../../../tooling/danger/rubocop_discourage_todo_addition'
+require_relative '../../../tooling/danger/rubocop_inline_disable_suggestion'
+require_relative '../../../tooling/danger/project_helper'
+
+RSpec.describe Tooling::Danger::RubocopHelper, feature_category: :tooling do
+  include_context 'with dangerfile'
+
+  let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) }
+  let(:rubocop) { fake_danger.new(helper: fake_helper) }
+
+  before do
+    allow(fake_helper).to receive(:changed_lines).and_return([])
+  end
+
+  describe 'rubocop inline disable suggestor danger' do
+    let(:all_changed_files) { %w[app/models/user.rb something.rb doc.md Gemfile] }
+    let(:draft_mr?) { false }
+
+    before do
+      allow(fake_helper).to receive(:all_changed_files).and_return(all_changed_files)
+      allow(fake_helper).to receive(:draft_mr?).and_return(draft_mr?)
+    end
+
+    it 'processes the right amount of files' do
+      expect(rubocop).to receive(:add_inline_disable_suggestions_for).exactly(3).times.and_call_original
+
+      rubocop.execute_inline_disable_suggestor
+    end
+
+    context 'when it is a draft mr' do
+      let(:draft_mr?) { true }
+
+      it 'does not perform any processing of files' do
+        expect(rubocop).not_to receive(:add_inline_disable_suggestions_for)
+
+        rubocop.execute_inline_disable_suggestor
+      end
+    end
+  end
+
+  describe 'rubocop discourage todo addition danger' do
+    using RSpec::Parameterized::TableSyntax
+
+    let(:fake_project_helper) { instance_double(Tooling::Danger::ProjectHelper) }
+
+    where do
+      {
+        'with only todo files' => {
+          all_changed_files: %w[.rubocop_todo/foo/foo.yml .rubocop_todo/this/bar.yml],
+          modified_files: %w[.rubocop_todo/foo/foo.yml .rubocop_todo/this/bar.yml],
+          suggest_calls: 0
+        },
+        'with only todo files and Gemfile.lock' => {
+          all_changed_files: %w[.rubocop_todo/foo/foo.yml .rubocop_todo/this/bar.yml Gemfile.lock],
+          modified_files: %w[.rubocop_todo/foo/foo.yml .rubocop_todo/this/bar.yml Gemfile.lock],
+          suggest_calls: 0
+        },
+        'with todo files and other files' => {
+          all_changed_files: %w[.rubocop_todo/foo/foo.yml .rubocop_todo/this/bar.yml app/models/user.rb],
+          modified_files: %w[.rubocop_todo/foo/foo.yml .rubocop_todo/this/bar.yml app/models/user.rb],
+          suggest_calls: 2
+        },
+        'with added or removed todo files and other files' => {
+          all_changed_files: %w[.rubocop_todo/foo/foo.yml app/models/user.rb],
+          modified_files: %w[app/models/user.rb],
+          suggest_calls: 0
+        },
+        'with todo files and Gemfile' => {
+          all_changed_files: %w[.rubocop_todo/foo/foo.yml Gemfile Gemfile],
+          modified_files: %w[.rubocop_todo/foo/foo.yml Gemfile Gemfile],
+          suggest_calls: 0
+        },
+        'with todo files and rubocop config file' => {
+          all_changed_files: %w[.rubocop_todo/foo/foo.yml .rubocop.yml],
+          modified_files: %w[.rubocop_todo/foo/foo.yml .rubocop.yml],
+          suggest_calls: 0
+        },
+        'with todo files Gemfile and other files' => {
+          all_changed_files: %w[.rubocop_todo/foo/foo.yml Gemfile app/models/user.rb],
+          modified_files: %w[.rubocop_todo/foo/foo.yml Gemfile app/models/user.rb],
+          suggest_calls: 0
+        }
+      }
+    end
+
+    with_them do
+      before do
+        allow(fake_helper).to receive(:all_changed_files).and_return(all_changed_files)
+        allow(fake_helper).to receive(:modified_files).and_return(modified_files)
+        allow(rubocop).to receive(:project_helper).and_return(fake_project_helper)
+        allow(fake_project_helper).to receive(:file_lines).and_return([])
+      end
+
+      specify do
+        expect(rubocop).to receive(:add_todo_suggestion_for).exactly(suggest_calls).times.and_call_original
+
+        rubocop.execute_todo_suggestor
+      end
+    end
+  end
+end
diff --git a/tooling/danger/rubocop_discourage_todo_addition.rb b/tooling/danger/rubocop_discourage_todo_addition.rb
new file mode 100644
index 0000000000000000000000000000000000000000..caca5b51bdf85674dbfcac42a480fe7bb7ad7293
--- /dev/null
+++ b/tooling/danger/rubocop_discourage_todo_addition.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require_relative 'suggestion'
+require_relative '../../rubocop/formatter/graceful_formatter'
+
+module Tooling
+  module Danger
+    class RubocopDiscourageTodoAddition < Suggestion
+      ONCE_PER_FILE = true
+      MATCH = %r{\s*-\s*['"].*['"]\s*}
+      REPLACEMENT = nil
+
+      SUGGESTION = <<~MESSAGE_MARKDOWN
+        Adding exclusions to RuboCop TODO files manually is discouraged.
+
+        If it is not possible to resolve the exception, then
+        [inline disabling](https://docs.gitlab.com/ee/development/rubocop_development_guide.html#disabling-rules-inline)
+        should be used.
+
+        ----
+
+        To reduce noise, this message will only appear once per file.
+      MESSAGE_MARKDOWN
+
+      def suggest
+        return if existing_grace_period? || added_grace_period?
+
+        super
+      end
+
+      private
+
+      def existing_grace_period?
+        project_helper
+          .file_lines(filename).grep(/\A\s*#{::RuboCop::Formatter::GracefulFormatter.grace_period_key_value}/).any?
+      end
+
+      def added_grace_period?
+        helper.changed_lines(filename).grep(/\s*#{::RuboCop::Formatter::GracefulFormatter.grace_period_key_value}/).any?
+      end
+    end
+  end
+end
diff --git a/tooling/danger/rubocop_helper.rb b/tooling/danger/rubocop_helper.rb
new file mode 100644
index 0000000000000000000000000000000000000000..9104e17bf6f4268ddd9107b378a09c0fe6b4f209
--- /dev/null
+++ b/tooling/danger/rubocop_helper.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module Tooling
+  module Danger
+    module RubocopHelper
+      def execute_inline_disable_suggestor
+        # This is noisy for draft MRs, so let's ignore this cop in draft mode since we have
+        # rubocop watching this as well.
+        return if helper.draft_mr?
+
+        # Danger should not comment when inline disables are added in the following files.
+        no_suggestions_for_extensions = %w[.md]
+
+        helper.all_changed_files.each do |filename|
+          next if filename.end_with?(*no_suggestions_for_extensions)
+
+          add_inline_disable_suggestions_for(filename)
+        end
+      end
+
+      def execute_todo_suggestor
+        # we'll assume a todo file modification only means regeneration/gem update/etc
+        return if contains_rubocop_update_files? || only_rubocop_todo_files?
+
+        modified_rubocop_todo_files.each do |filename|
+          add_todo_suggestion_for(filename)
+        end
+      end
+
+      private
+
+      def contains_rubocop_update_files?
+        # this will help be a signal for valid todo file additions or changes
+        helper.all_changed_files.any? { |path| path =~ %r{\A(Gemfile(\z|.lock\z)|.rubocop.yml\z)} }
+      end
+
+      def only_rubocop_todo_files?
+        # this will help be a signal that this is change only has todo files in it
+        helper.all_changed_files.none? { |path| path !~ %r{\A\.rubocop_todo/.*/\w+.yml\b} }
+      end
+
+      def modified_rubocop_todo_files
+        files = helper.modified_files
+
+        files.select { |path| path =~ %r{\A\.rubocop_todo/.*/\w+.yml\b} }
+      end
+
+      def add_todo_suggestion_for(filename)
+        Tooling::Danger::RubocopDiscourageTodoAddition.new(filename, context: self).suggest
+      end
+
+      def add_inline_disable_suggestions_for(filename)
+        Tooling::Danger::RubocopInlineDisableSuggestion.new(filename, context: self).suggest
+      end
+    end
+  end
+end
diff --git a/tooling/danger/suggestion.rb b/tooling/danger/suggestion.rb
index ff1ca83876deafbe0744e657a31fd92f321672af..83984cb0b0a47a4b1576e58055e801cee7e6f2c0 100644
--- a/tooling/danger/suggestion.rb
+++ b/tooling/danger/suggestion.rb
@@ -15,6 +15,8 @@ module Danger
     class Suggestion
       include ::Tooling::Danger::Suggestor
 
+      ONCE_PER_FILE = false
+
       attr_reader :filename
 
       def initialize(filename, context:)
@@ -27,7 +29,8 @@ def suggest
           filename: filename,
           regex: self.class::MATCH,
           replacement: self.class::REPLACEMENT,
-          comment_text: self.class::SUGGESTION
+          comment_text: self.class::SUGGESTION,
+          once_per_file: self.class::ONCE_PER_FILE
         )
       end
 
diff --git a/tooling/danger/suggestor.rb b/tooling/danger/suggestor.rb
index c3f886b0af34795cd97b9c7dc14f2b69cfe35d80..0e61005629d14b3140af8197831e0bd27afc2e32 100644
--- a/tooling/danger/suggestor.rb
+++ b/tooling/danger/suggestor.rb
@@ -4,7 +4,7 @@ module Tooling
   module Danger
     module Suggestor
       # For file lines matching `regex` adds suggestion `replacement` with `comment_text` added.
-      def add_suggestion(filename:, regex:, replacement: nil, comment_text: nil, exclude: nil)
+      def add_suggestion(filename:, regex:, replacement: nil, comment_text: nil, exclude: nil, once_per_file: false)
         added_lines = added_lines_matching(filename, regex)
 
         return if added_lines.empty?
@@ -12,6 +12,8 @@ def add_suggestion(filename:, regex:, replacement: nil, comment_text: nil, exclu
         file_lines = project_helper.file_lines(filename)
 
         added_lines.each_with_object([]) do |added_line, processed_line_numbers|
+          break if once_per_file && processed_line_numbers.any?
+
           line_number = find_line_number(file_lines, added_line.delete_prefix('+'),
 exclude_indexes: processed_line_numbers)