# 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