# 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 RESULTS_SECTION_TEMPLATE = "\n\n### DO NOT EDIT BELOW THIS LINE\n\nActive and historical test results:" 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| puts "Reporting test: #{test.file} | #{test.name}\n" report_test(test) unless test.skipped end test_results.write end end def report_test(test) testcase = find_testcase(test) || create_testcase(test) test.testcase ||= testcase.web_url.sub('/issues/', '/quality/test_cases/') issue = find_linked_results_issue_by_iid(testcase, test) if issue issue = update_issue_title(issue, test, 'issue') if issue.title.strip != title_from_test(test) else puts "No valid issue link found" issue = find_or_create_results_issue(test) add_issue_to_testcase(testcase, issue) end update_labels(testcase, test) update_issue(issue, test) end def find_testcase(test) testcase = find_testcase_by_iid(test) if testcase testcase = update_issue_title(testcase, test, 'test_case') if testcase.title.strip != title_from_test(test) else testcase = find_issue(test, 'test_case') end testcase end def find_testcase_by_iid(test) iid = testcase_iid_from_url(test.testcase) return unless iid find_issue_by_iid(iid, 'test_case') end def find_linked_results_issue_by_iid(testcase, test) iid = issue_iid_from_testcase(testcase) return unless iid find_issue_by_iid(iid, 'issue') end def find_issue_by_iid(iid, issue_type) issues = gitlab.find_issues(iid: iid) do |issue| issue.state == 'opened' && issue.issue_type == issue_type end warn(%(#{issue_type} iid "#{iid}" not valid)) if issues.empty? issues.first end def update_issue_title(issue, test, issue_type) warn(%(#{issue_type} title needs to be updated from '#{issue.title.strip}' to '#{title_from_test(test)}')) gitlab.edit_issue(iid: issue.iid, options: { title: title_from_test(test) }) end def create_testcase(test) title = title_from_test(test) puts "Creating test case '#{title}' ..." gitlab.create_issue( title: title, description: new_testcase_description(test), labels: new_issue_labels(test), issue_type: 'test_case' ) end def testcase_iid_from_url(url) return warn(%(\nPlease update #{url} to test case url")) if url&.include?('/-/issues/') url && url.split('/').last.to_i end def new_testcase_description(test) "#{new_issue_description(test)}#{RESULTS_SECTION_TEMPLATE}" end def issue_iid_from_testcase(testcase) results = testcase.description.partition(RESULTS_SECTION_TEMPLATE).last if testcase.description.include?(RESULTS_SECTION_TEMPLATE) return puts "No issue link found" unless results issue_iid = results.split('/').last issue_iid&.to_i end def find_or_create_results_issue(test) issue = find_issue(test, 'issue') if issue puts "Found existing issue: #{issue.web_url}" else issue = create_issue(test) puts "Created new issue: #{issue.web_url}" end issue end def find_issue(test, issue_type) issues = gitlab.find_issues(options: { search: search_term(test) }) do |issue| issue.state == 'opened' && issue.issue_type == issue_type && issue.title.strip == title_from_test(test) end warn(%(Too many #{issue_type}s found with the file path "#{test.file}" and name "#{test.name}")) if issues.many? issues.first end def add_issue_to_testcase(testcase, issue) results_section = testcase.description.include?(RESULTS_SECTION_TEMPLATE) ? '' : RESULTS_SECTION_TEMPLATE gitlab.edit_issue(iid: testcase.iid, options: { description: (testcase.description + results_section + "\n\n#{issue.web_url}") }) puts "Added issue #{issue.web_url} to testcase #{testcase.web_url}" end def update_issue(issue, test) new_labels = issue_labels(issue) new_labels |= ['Testcase Linked'] labels_updated = update_labels(issue, test, new_labels) note_posted = note_status(issue, test) if labels_updated || note_posted puts "Issue updated." else puts "Test passed, no update needed." end end def new_issue_labels(test) ['Quality', "devops::#{test.stage}", 'status::automated'] end def up_to_date_labels(test:, issue: nil, new_labels: Set.new) labels = super labels |= new_issue_labels(test).to_set labels.delete_if { |label| label.start_with?("#{pipeline}::") } labels << (test.failures.empty? ? "#{pipeline}::passed" : "#{pipeline}::failed") 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