# 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) @token = token @files = Array(input_files) @project = project @retry_backoff = 0 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}" extension = File.extname(file) case extension when '.json' test_results = Report::JsonTestResults.new(file) when '.xml' test_results = Report::JUnitTestResults.new(file) else raise "Unknown extension #{extension}" end test_results.each do |test| report_test(test) end end end private attr_reader :files, :token, :project 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 # rubocop:disable Metrics/AbcSize def find_issue(test) handle_gitlab_client_exceptions do return Gitlab.issue(project, id_from_testcase_url(test.testcase)) if test.testcase 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 # rubocop:enable Metrics/AbcSize def id_from_testcase_url(url) url.split('/').last.to_i 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::OpenTimeout, Net::ReadTimeout, Gitlab::Error::InternalServerError, 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