lib/gitlab/qa/report/results_in_issues.rb



# frozen_string_literal: true

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

module Gitlab
  module QA
    module Report
      # Uses the API to create or update GitLab issues with the results of tests from RSpec report files.
      class ResultsInIssues < ReportAsIssue
        private

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

          test_results_per_file do |test_results|
            puts "Reporting tests in #{test_results.path}"

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

            test_results.write
          end
        end

        def report_test(test)
          puts "Reporting test: #{test.file} | #{test.name}"

          issue = find_issue(test)

          if issue
            puts "Found existing issue: #{issue.web_url}"
          else
            # Don't create new issues for skipped tests
            return if test.skipped

            issue = create_issue(test)
            puts "Created new issue: #{issue.web_url}"
          end

          test.testcase ||= issue.web_url

          labels_updated = update_labels(issue, test)
          note_posted = note_status(issue, test)

          if labels_updated || note_posted
            puts "Issue updated."
          else
            puts "Test passed, no update needed."
          end
        end

        def find_issue(test)
          title = title_from_test(test)
          issues =
            gitlab.find_issues(
              iid: iid_from_testcase_url(test.testcase),
              options: { search: search_term(test) }) do |issue|
                issue.state == 'opened' && issue.title.strip == title
              end

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

          issues.first
        end

        def new_issue_labels(test)
          %w[status::automated]
        end

        def up_to_date_labels(test:, issue: nil)
          labels = super
          labels.delete_if { |label| label.start_with?("#{pipeline}::") }
          labels << (test.failures.empty? ? "#{pipeline}::passed" : "#{pipeline}::failed")
        end

        def iid_from_testcase_url(url)
          url && url.split('/').last.to_i
        end

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

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

          note = note_content(test)

          gitlab.find_issue_discussions(iid: issue.iid).each do |discussion|
            return gitlab.add_note_to_issue_discussion_as_thread(iid: issue.iid, discussion_id: discussion.id, body: failure_summary) if new_note_matches_discussion?(note, discussion)
          end

          gitlab.create_issue_note(iid: issue.iid, note: note)

          true
        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 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)
          text.strip[/Error:(.*)/m, 1].to_s
        end
      end
    end
  end
end