lib/gitlab/qa/report/results_in_issues.rb



# frozen_string_literal: true

require 'nokogiri'
require 'gitlab'
require 'active_support/core_ext/enumerable'

module Gitlab
  # Monkey patch the Gitlab client to use the correct API path and add required methods
  class Client
    def team_member(project, id)
      get("/projects/#{url_encode(project)}/members/all/#{id}")
    end

    def issue_discussions(project, issue_id, options = {})
      get("/projects/#{url_encode(project)}/issues/#{issue_id}/discussions", query: options)
    end

    def add_note_to_issue_discussion_as_thread(project, issue_id, discussion_id, options = {})
      post("/projects/#{url_encode(project)}/issues/#{issue_id}/discussions/#{discussion_id}/notes", query: options)
    end
  end

  module QA
    module Report
      # Uses the API to create or update GitLab issues with the results of tests from RSpec report files.
      # The GitLab client is used for API access: https://github.com/NARKOZ/gitlab
      class ResultsInIssues
        MAINTAINER_ACCESS_LEVEL = 40
        MAX_TITLE_LENGTH = 255
        RETRY_BACK_OFF_DELAY = 60
        MAX_RETRY_ATTEMPTS = 3

        def initialize(token:, input_files:, project: nil, input_format: :junit)
          @token = token
          @files = Array(input_files)
          @project = project
          @retry_backoff = 0
          @input_format = input_format.to_sym
        end

        def invoke!
          configure_gitlab_client

          validate_input!

          puts "Reporting test results in `#{files.join(',')}` as issues in project `#{project}` via the API at `#{Runtime::Env.gitlab_api_base}`."

          Dir.glob(files).each do |file|
            puts "Reporting tests in #{file}"
            case input_format
            when :json
              test_results = Report::JsonTestResults.new(file)
            when :junit
              test_results = Report::JUnitTestResults.new(file)
            end

            test_results.each do |test|
              report_test(test)
            end
          end
        end

        private

        attr_reader :files, :token, :project, :input_format

        def validate_input!
          assert_project!
          assert_input_files!(files)
          assert_user_permission!
        end

        def assert_project!
          return if project

          abort "Please provide a valid project ID or path with the `-p/--project` option!"
        end

        def assert_input_files!(files)
          return if Dir.glob(files).any?

          abort "Please provide valid JUnit report files. No files were found matching `#{files.join(',')}`"
        end

        def assert_user_permission!
          handle_gitlab_client_exceptions do
            user = Gitlab.user
            member = Gitlab.team_member(project, user.id)

            abort_not_permitted if member.access_level < MAINTAINER_ACCESS_LEVEL
          end
        rescue Gitlab::Error::NotFound
          abort_not_permitted
        end

        def abort_not_permitted
          abort "You must have at least Maintainer access to the project to use this feature."
        end

        def configure_gitlab_client
          handle_gitlab_client_exceptions do
            Gitlab.configure do |config|
              config.endpoint = Runtime::Env.gitlab_api_base
              config.private_token = token
            end
          end
        end

        def report_test(test)
          return if test.skipped

          puts "Reporting test: #{test.file} | #{test.name}"

          issue = find_issue(test)
          if issue
            puts "Found existing issue: #{issue.web_url}"
          else
            issue = create_issue(test)
            puts "Created new issue: #{issue.web_url}"
          end

          update_labels(issue, test)
          note_status(issue, test)

          puts "Issue updated"
        end

        def create_issue(test)
          puts "Creating issue..."

          handle_gitlab_client_exceptions do
            Gitlab.create_issue(
              project,
              title_from_test(test),
              { description: "### Full description\n\n#{search_safe(test.name)}\n\n### File path\n\n#{test.file}", labels: 'status::automated' }
            )
          end
        end

        def find_issue(test)
          handle_gitlab_client_exceptions do
            issues = Gitlab.issues(project, { search: search_term(test) })
              .auto_paginate
              .select { |issue| issue.state == 'opened' && issue.title.strip == title_from_test(test) }

            warn(%(Too many issues found with the file path "#{test.file}" and name "#{test.name}")) if issues.many?

            issues.first
          end
        end

        def search_term(test)
          %("#{test.file}" "#{search_safe(test.name)}")
        end

        def title_from_test(test)
          title = "#{partial_file_path(test.file)} | #{search_safe(test.name)}".strip

          return title unless title.length > MAX_TITLE_LENGTH

          "#{title[0...MAX_TITLE_LENGTH - 3]}..."
        end

        def partial_file_path(path)
          path.match(/((api|browser_ui).*)/i)[1]
        end

        def search_safe(value)
          value.delete('"')
        end

        def note_status(issue, test)
          return if test.failures.empty?

          note = note_content(test)

          handle_gitlab_client_exceptions do
            Gitlab.issue_discussions(project, issue.iid, order_by: 'created_at', sort: 'asc').each do |discussion|
              return add_note_to_discussion(issue.iid, discussion.id) if new_note_matches_discussion?(note, discussion)
            end

            Gitlab.create_issue_note(project, issue.iid, note)
          end
        end

        def note_content(test)
          errors = test.failures.each_with_object([]) do |failure, text|
            text << <<~TEXT
              Error:
              ```
              #{failure['message']}
              ```

              Stacktrace:
              ```
              #{failure['stacktrace']}
              ```
            TEXT
          end.join("\n\n")

          "#{failure_summary}\n\n#{errors}"
        end

        def failure_summary
          summary = [":x: ~\"#{pipeline}::failed\""]
          summary << "~\"quarantine\"" if quarantine_job?
          summary << "in job `#{Runtime::Env.ci_job_name}` in #{Runtime::Env.ci_job_url}"
          summary.join(' ')
        end

        def quarantine_job?
          Runtime::Env.ci_job_name&.include?('quarantine')
        end

        def new_note_matches_discussion?(note, discussion)
          note_error = error_and_stack_trace(note)
          discussion_error = error_and_stack_trace(discussion.notes.first['body'])

          return false if note_error.empty? || discussion_error.empty?

          note_error == discussion_error
        end

        def error_and_stack_trace(text)
          result = text.strip[/Error:(.*)/m, 1].to_s

          warn "Could not find `Error:` in text: #{text}" if result.empty?

          result
        end

        def add_note_to_discussion(issue_iid, discussion_id)
          handle_gitlab_client_exceptions do
            Gitlab.add_note_to_issue_discussion_as_thread(project, issue_iid, discussion_id, body: failure_summary)
          end
        end

        # rubocop:disable Metrics/AbcSize
        def update_labels(issue, test)
          labels = issue.labels
          labels.delete_if { |label| label.start_with?("#{pipeline}::") }
          labels << (test.failures.empty? ? "#{pipeline}::passed" : "#{pipeline}::failed")
          labels << "Enterprise Edition" if ee_test?(test)
          quarantine_job? ? labels << "quarantine" : labels.delete("quarantine")

          handle_gitlab_client_exceptions do
            Gitlab.edit_issue(project, issue.iid, labels: labels)
          end
        end
        # rubocop:enable Metrics/AbcSize

        def ee_test?(test)
          test.file =~ %r{features/ee/(api|browser_ui)}
        end

        def pipeline
          # Gets the name of the pipeline the test was run in, to be used as the key of a scoped label
          #
          # Tests can be run in several pipelines:
          #   gitlab-qa, nightly, master, staging, canary, production, preprod, and MRs
          #
          # Some of those run in their own project, so CI_PROJECT_NAME is the name we need. Those are:
          #   nightly, staging, canary, production, and preprod
          #
          # MR, master, and gitlab-qa tests run in gitlab-qa, but we only want to report tests run on master
          # because the other pipelines will be monitored by the author of the MR that triggered them.
          # So we assume that we're reporting a master pipeline if the project name is 'gitlab-qa'.

          Runtime::Env.pipeline_from_project_name
        end

        def handle_gitlab_client_exceptions
          yield
        rescue Gitlab::Error::NotFound
          # This error could be raised in assert_user_permission!
          # If so, we want it to terminate at that point
          raise
        rescue SystemCallError, OpenSSL::SSL::SSLError, Net::ReadTimeout, Gitlab::Error::Parsing => e
          @retry_backoff += RETRY_BACK_OFF_DELAY

          raise if @retry_backoff > RETRY_BACK_OFF_DELAY * MAX_RETRY_ATTEMPTS

          warn_exception(e)
          warn("Sleeping for #{@retry_backoff} seconds before retrying...")
          sleep @retry_backoff

          retry
        rescue StandardError => e
          pipeline = QA::Runtime::Env.pipeline_from_project_name
          channel = pipeline == "canary" ? "qa-production" : "qa-#{pipeline}"
          error_msg = warn_exception(e)
          slack_options = {
            channel: channel,
            icon_emoji: ':ci_failing:',
            message: <<~MSG
              An unexpected error occurred while reporting test results in issues.
              The error occurred in job: #{QA::Runtime::Env.ci_job_url}
              `#{error_msg}`
            MSG
          }
          puts "Posting Slack message to channel: #{channel}"

          Gitlab::QA::Slack::PostToSlack.new(**slack_options).invoke!
        end

        def warn_exception(error)
          error_msg = "#{error.class.name} #{error.message}"
          warn(error_msg)
          error_msg
        end
      end
    end
  end
end