Why Gemfury? Push, build, and install  RubyGems npm packages Python packages Maven artifacts PHP packages Go Modules Debian packages RPM packages NuGet packages

Repository URL to install this package:

Details    
teak-dev / lib / teak / dev / tasks.rb
Size: Mime:
# frozen_string_literal: true

module Teak
  module Dev
    # Class for setting up our Rake tasks.
    class Tasks # rubocop:disable Metrics/ClassLength
      include Rake::DSL if defined? Rake::DSL

      attr_reader :service

      NON_CANDIDATE_COPS = %w[
        Style/FrozenStringLiteralComment
        Rake/Desc
      ].freeze

      def self.install(opts)
        new(opts[:service]).install
      end

      def initialize(service)
        @service = service
        @rubocop_todo_before = nil
      end

      def install
        install_lint
        install_report
        install_rubocop
        install_terraform
      end

    private

      def install_lint # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
        lint_tasks = %w[lint:rubocop lint:bundler_audit]
        namespace :lint do
          desc 'Run Rubocop.'
          task :rubocop do
            # N.B. Not using `RuboCop::RakeTask.new` because it's not available during image builds, which
            # was causing issues since we _do_ need rake tasks available.
            sh 'bundle exec rubocop'
          end

          has_reek = true
          begin
            require 'reek/version'
          rescue LoadError
            has_reek = false
          end

          if has_reek
            lint_tasks << 'lint:reek'

            desc 'Run Reek.'
            task :reek do
              sh 'bundle exec reek'
            end
          end

          desc 'Run bundler-audit.'
          task :bundler_audit do
            sh 'bundle exec bundle-audit check --update'
          end
        end

        desc 'Run all lint tasks.'
        task lint: lint_tasks

        namespace :fix do
          desc 'Run Rubocop with the autocorrect option.  This will only apply fixes deemed safe.'
          task :rubocop do
            # N.B. Not using `RuboCop::RakeTask.new` because it's not available during image builds, which
            # was causing issues since we _do_ need rake tasks available.
            sh 'bundle exec rubocop --autocorrect'
          end
        end

        desc 'Run linters in fix mode, where appropriate.'
        task fix: %w[
          fix:rubocop
        ]
      end

      def install_report # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
        namespace :report do
          task :init_datadog_client do
            init_dd_client!
          end

          desc 'Report count of unresolved Rubocop offenses (ignoring TODO file) to DataDog.'
          task rubocop: :init_datadog_client do
            with_no_rubocop_todos do
              raw_output = `bundle exec rubocop --format=offenses 2>/dev/null | grep -v =`
              puts raw_output
              total_line = raw_output.split("\n").find { |line| line.include?('Total in') }
              total = total_line.split(/\s+/).first.to_i

              report_for_branch('rubocop.offenses', service, total)
            end
          end

          desc 'Report Ruby code coverage to DataDog.'
          task coverage: :init_datadog_client do
            require 'simplecov'

            SimpleCov.collate(Dir['/tmp/coverage/**/.resultset.json'])
            total_covered_percent = SimpleCov.result.coverage_statistics[:line].percent

            puts "Total coverage: #{total_covered_percent}%"

            report_for_branch('coverage.rspec', service, total_covered_percent)
          end
        end
      end

      def with_no_rubocop_todos
        if File.exist?('.rubocop_todo.yml')
          @rubocop_todo_before = File.read('.rubocop_todo.yml')
          File.write('.rubocop_todo.yml', '')
        end

        begin
          yield
        ensure
          restore_rubocop_todos
        end
      end

      def restore_rubocop_todos
        File.write('.rubocop_todo.yml', @rubocop_todo_before) if @rubocop_todo_before
      end

      def init_dd_client!
        require "datadog_api_client"
        DatadogAPIClient.configure do |config|
          # config.debugging = true
          # config.api_key = ENV['DD_API_KEY']
          # config.application_key = ENV['DD_APP_KEY']
          config.server_variables[:site] = ENV['DD_SITE'] if ENV['DD_SITE']
        end
        @dd_client = DatadogAPIClient::APIClient.new
      end

      def report_for_branch(metric_name, service_name, value)
        return unless @dd_client

        branch_name = ENV.fetch('CIRCLE_BRANCH', 'unknown').gsub(/[^.a-zA-Z0-9_-]/, '--')[0..255]

        metrics_api = DatadogAPIClient::V2::MetricsAPI.new(@dd_client)
        metric_series = DatadogAPIClient::V2::MetricSeries.new(
          metric: metric_name,
          type:   DatadogAPIClient::V2::MetricIntakeType::GAUGE,
          points: [
            DatadogAPIClient::V2::MetricPoint.new(timestamp: Time.now.to_i, value: value),
          ],
          tags:   ["service:#{service_name}", "branch:#{branch_name}"]
        )

        metrics_api.submit_metrics(DatadogAPIClient::V2::MetricPayload.new(series: [metric_series]))

        puts "Reported #{metric_name}=#{value} for service=#{service_name}, branch=#{branch_name}"
      rescue StandardError => e
        puts "Failed to report #{metric_name} to Datadog: #{e}"
      end

      def rubocop_data_for(cop_name, extra_processing = nil)
        cmd =
          "bundle exec rubocop --ignore-disable-comments --only #{cop_name} | " \
          "grep #{cop_name}: | " \
          "sed 's/\\[Correctable\\]//' | " \
          "cut -d'[' -f2 | "
        cmd += "#{extra_processing} | " if extra_processing
        cmd += 'cut -d/ -f1 | sort -n'
        # puts cmd
        `#{cmd}`
      end

      # :reek:UtilityFunction
      def output_to_samples(output)
        # puts output
        output.split("\n").map(&:strip).reject(&:empty?).map(&:to_f)
      end

      # :reek:TooManyStatements
      # :reek:UtilityFunction
      # :reek:UncommunicativeVariableName
      def compute_stats(samples)
        return { median: 0, p95: 0, average: 0 } if samples.empty?

        sorted  = samples.sort
        count   = sorted.length
        median  = sorted[count / 2]
        p95     = sorted[(count * 0.95).ceil - 1]
        average = sorted.sum / count.to_f

        {
          median:  median.round(1),
          p95:     p95.round(1),
          average: average.round(1),
        }
      end

      def install_rubocop # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength
        namespace :rubocop do # rubocop:disable Metrics/BlockLength
          desc 'Regenerate the .rubocop_todo.yml file.'
          task :regenerate_todo do
            File.write('.rubocop_todo.yml', '')
            sh 'bundle exec rubocop --auto-gen-config --no-exclude-limit --auto-gen-only-exclude ' \
               '--no-auto-gen-enforced-style'
          end

          desc 'Find candidates for incremental TODO paydown.'
          task :paydown_candidates do # rubocop:disable Metrics/BlockLength
            todo_contents = File.read('.rubocop_todo.yml')
            File.write('.rubocop_todo.yml', '')
            raw_offense_info =
              `bundle exec rubocop --format=offenses`
                .split("\n")
                .grep(/^\d+/)
                .map { |line| line.split(/\s+/) }
                .map { |pieces| [pieces[0].to_i, pieces[1], pieces[2]&.sub('[', '') || 'N/A'] }
                .reject { |offense| NON_CANDIDATE_COPS.include?(offense[1]) }

            # Prioritize offenses that are safe to correct...
            most_violated_cop = raw_offense_info.find { |offense| offense[2] == 'Safe' }
            # Then prioritize ones we're just going to mark with ignore comments...
            most_violated_cop ||= raw_offense_info.find { |offense| offense[2] == 'N/A' }
            # Then just pick the most violated cop if we have to...
            most_violated_cop ||= raw_offense_info.first

            is_uncorrectable = most_violated_cop[2] == 'N/A'
            is_unsafe        = most_violated_cop[2] == 'Unsafe'
            is_rspec         = most_violated_cop[1] =~ %r{^RSpec/}

            puts "Most violated cop: #{most_violated_cop[1]} (Correctability: #{most_violated_cop[2]}; " \
                 "#{most_violated_cop[0]} offenses total)"
            puts

            raw_offender_info = `bundle exec rubocop --format=worst --only #{most_violated_cop[1]}`
                                  .split("\n")
                                  .grep(/^\d+/)
                                  .grep_v(/Total/)
            offense_count     = 0
            worst_offenders   = []
            unless is_uncorrectable
              # N.B. If it's unsafe, but it's RSpec, we can plow ahead because it'll surface quickly if something
              # breaks.
              #
              # Also: This is madness!!
              offense_threshold = is_unsafe && !is_rspec ? 30 : 300
              file_threshold    = is_unsafe && !is_rspec ? 10 : 50
              raw_offender_info.each do |line|
                pieces = line.split(/\s+/)
                offense_count += pieces[0].to_i
                worst_offenders << pieces[1]
                break if offense_count >= offense_threshold
                break if worst_offenders.length >= file_threshold
              end
              puts "Worst offenders (#{offense_count} offenses total):"
              puts(worst_offenders.map { |offender| "  #{offender}" })
              puts
            end

            common_prefix = "echo > .rubocop_todo.yml; rubocop --only #{most_violated_cop[1]}"
            common_suffix = '; rake rubocop:regenerate_todo'
            puts 'Next steps:'
            if is_uncorrectable
              puts "#{common_prefix} --autocorrect --disable-uncorrectable#{common_suffix}"
            elsif is_unsafe
              puts "#{common_prefix} --autocorrect-all #{worst_offenders.join(' ')}#{common_suffix}"
              puts
              puts 'WARNING: This is an unsafe correction!  You MUST validate ALL changes!  ' \
                   'You MUST include the fact that this is an unsafe correction when filing your PR!'
            else
              puts "#{common_prefix} --autocorrect #{worst_offenders.join(' ')}#{common_suffix}"
            end
          ensure
            File.write('.rubocop_todo.yml', todo_contents)
          end

          desc 'Measure various metrics about the codebase, and report median, p95, and average for each.'
          task :metrics do # rubocop:disable Metrics/BlockLength
            puts 'IMPORTANT: Only run this tool after disabling any existing thresholds for the relevant cops in ' \
                 '.rubocop.yml!'

            with_no_rubocop_todos do
              line_length = compute_stats(output_to_samples(rubocop_data_for('Layout/LineLength')))
              puts "Layout/LineLength: Median=#{line_length[:median]}, p95=#{line_length[:p95]}, " \
                   "Average=#{line_length[:average]}"

              abc = compute_stats(output_to_samples(rubocop_data_for('Metrics/AbcSize', "cut -d' ' -f4")))
              puts "Metrics/AbcSize: Median=#{abc[:median]}, p95=#{abc[:p95]}, " \
                   "Average=#{abc[:average]}"

              block_length = compute_stats(output_to_samples(rubocop_data_for('Metrics/BlockLength')))
              puts "Metrics/BlockLength: Median=#{block_length[:median]}, p95=#{block_length[:p95]}, " \
                   "Average=#{block_length[:average]}"

              class_length = compute_stats(output_to_samples(rubocop_data_for('Metrics/ClassLength')))
              puts "Metrics/ClassLength: Median=#{class_length[:median]}, p95=#{class_length[:p95]}, " \
                   "Average=#{class_length[:average]}"

              cyclomatic_complexity = compute_stats(output_to_samples(rubocop_data_for('Metrics/CyclomaticComplexity')))
              puts "Metrics/CyclomaticComplexity: Median=#{cyclomatic_complexity[:median]}, " \
                   "p95=#{cyclomatic_complexity[:p95]}, Average=#{cyclomatic_complexity[:average]}"

              method_length = compute_stats(output_to_samples(rubocop_data_for('Metrics/MethodLength')))
              puts "Metrics/MethodLength: Median=#{method_length[:median]}, " \
                   "p95=#{method_length[:p95]}, Average=#{method_length[:average]}"

              parameter_lists = compute_stats(output_to_samples(rubocop_data_for('Metrics/ParameterLists')))
              puts "Metrics/ParameterLists: Median=#{parameter_lists[:median]}, p95=#{parameter_lists[:p95]}, " \
                   "Average=#{parameter_lists[:average]}"

              perceived_complexity = compute_stats(output_to_samples(rubocop_data_for('Metrics/PerceivedComplexity')))
              puts "Metrics/PerceivedComplexity: Median=#{perceived_complexity[:median]}, " \
                   "p95=#{perceived_complexity[:p95]}, Average=#{perceived_complexity[:average]}"

              if defined?(RSpec)
                example_length = compute_stats(output_to_samples(rubocop_data_for('RSpec/ExampleLength')))
                puts "RSpec/ExampleLength: Median=#{example_length[:median]}, p95=#{example_length[:p95]}, " \
                     "Average=#{example_length[:average]}"
              end
            end
          end
        end
      end

      # :reek:TooManyStatements
      def install_terraform
        return unless Dir.exist?('./terraform')

        namespace :terraform do
          desc 'Updates all terraform providers and modules'
          task :update do
            sh 'terraform -chdir=terraform init -upgrade'
            sh 'terraform -chdir=terraform providers lock -platform=darwin_arm64 -platform=darwin_amd64 ' \
               '-platform=linux_arm64 -platform=linux_amd64'
          end
        end
      end
    end
  end
end