gitlab_quality-test_tooling 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +9 -4
- data/Gemfile.lock +1 -1
- data/Guardfile +0 -22
- data/README.md +150 -9
- data/exe/generate-test-session +50 -0
- data/exe/post-to-slack +58 -0
- data/exe/prepare-stage-reports +38 -0
- data/exe/relate-failure-issue +59 -0
- data/exe/report-results +56 -0
- data/exe/update-screenshot-paths +38 -0
- data/lib/gitlab_quality/test_tooling/gitlab_issue_client.rb +194 -0
- data/lib/gitlab_quality/test_tooling/gitlab_issue_dry_client.rb +26 -0
- data/lib/gitlab_quality/test_tooling/report/concerns/find_set_dri.rb +51 -0
- data/lib/gitlab_quality/test_tooling/report/concerns/results_reporter.rb +75 -0
- data/lib/gitlab_quality/test_tooling/report/concerns/utils.rb +49 -0
- data/lib/gitlab_quality/test_tooling/report/generate_test_session.rb +275 -0
- data/lib/gitlab_quality/test_tooling/report/prepare_stage_reports.rb +78 -0
- data/lib/gitlab_quality/test_tooling/report/relate_failure_issue.rb +377 -0
- data/lib/gitlab_quality/test_tooling/report/report_as_issue.rb +134 -0
- data/lib/gitlab_quality/test_tooling/report/report_results.rb +83 -0
- data/lib/gitlab_quality/test_tooling/report/results_in_issues.rb +130 -0
- data/lib/gitlab_quality/test_tooling/report/results_in_testcases.rb +113 -0
- data/lib/gitlab_quality/test_tooling/report/update_screenshot_path.rb +81 -0
- data/lib/gitlab_quality/test_tooling/runtime/env.rb +113 -0
- data/lib/gitlab_quality/test_tooling/runtime/logger.rb +92 -0
- data/lib/gitlab_quality/test_tooling/runtime/token_finder.rb +44 -0
- data/lib/gitlab_quality/test_tooling/slack/post_to_slack.rb +36 -0
- data/lib/gitlab_quality/test_tooling/summary_table.rb +41 -0
- data/lib/gitlab_quality/test_tooling/support/http_request.rb +34 -0
- data/lib/gitlab_quality/test_tooling/system_logs/finders/json_log_finder.rb +65 -0
- data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/api_log_finder.rb +21 -0
- data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/application_log_finder.rb +21 -0
- data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/exception_log_finder.rb +21 -0
- data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/graphql_log_finder.rb +21 -0
- data/lib/gitlab_quality/test_tooling/system_logs/log_types/log.rb +38 -0
- data/lib/gitlab_quality/test_tooling/system_logs/log_types/rails/api_log.rb +34 -0
- data/lib/gitlab_quality/test_tooling/system_logs/log_types/rails/application_log.rb +27 -0
- data/lib/gitlab_quality/test_tooling/system_logs/log_types/rails/exception_log.rb +23 -0
- data/lib/gitlab_quality/test_tooling/system_logs/log_types/rails/graphql_log.rb +30 -0
- data/lib/gitlab_quality/test_tooling/system_logs/shared_fields.rb +29 -0
- data/lib/gitlab_quality/test_tooling/system_logs/system_logs_formatter.rb +65 -0
- data/lib/gitlab_quality/test_tooling/test_results/base_test_results.rb +39 -0
- data/lib/gitlab_quality/test_tooling/test_results/builder.rb +35 -0
- data/lib/gitlab_quality/test_tooling/test_results/j_unit_test_results.rb +27 -0
- data/lib/gitlab_quality/test_tooling/test_results/json_test_results.rb +29 -0
- data/lib/gitlab_quality/test_tooling/test_results/test_result.rb +184 -0
- data/lib/gitlab_quality/test_tooling/version.rb +1 -1
- data/lib/gitlab_quality/test_tooling.rb +11 -2
- metadata +51 -3
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'nokogiri'
|
4
|
+
|
5
|
+
module GitlabQuality
|
6
|
+
module TestTooling
|
7
|
+
module Report
|
8
|
+
class PrepareStageReports
|
9
|
+
EXTRACT_STAGE_FROM_TEST_FILE_REGEX = %r{(?:api|browser_ui)/(?:[0-9]+_)?(?<stage>[_\w]+)/}i
|
10
|
+
|
11
|
+
def initialize(junit_files:)
|
12
|
+
@junit_files = junit_files
|
13
|
+
end
|
14
|
+
|
15
|
+
def invoke!
|
16
|
+
collate_test_cases.each do |stage, tests|
|
17
|
+
filename = "#{stage}.xml"
|
18
|
+
|
19
|
+
File.write(filename, junit_report(tests).to_s)
|
20
|
+
|
21
|
+
puts "Saved #{filename}"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
attr_reader :junit_files
|
28
|
+
|
29
|
+
# Collect the test cases from the original reports and group them by Stage
|
30
|
+
def collate_test_cases
|
31
|
+
Dir.glob(junit_files)
|
32
|
+
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |junit_file, test_cases|
|
33
|
+
report = Nokogiri::XML(File.open(junit_file))
|
34
|
+
report.xpath('//testcase').each do |test_case|
|
35
|
+
# The test file paths could start with any of
|
36
|
+
# /qa/specs/features/api/<stage>
|
37
|
+
# /qa/specs/features/browser_ui/<stage>
|
38
|
+
# /qa/specs/features/ee/api/<stage>
|
39
|
+
# /qa/specs/features/ee/browser_ui/<stage>
|
40
|
+
# For now we assume the Stage is whatever follows api/ or browser_ui/
|
41
|
+
test_file_match = test_case['file'].match(EXTRACT_STAGE_FROM_TEST_FILE_REGEX)
|
42
|
+
next unless test_file_match
|
43
|
+
|
44
|
+
stage = test_file_match[:stage]
|
45
|
+
test_cases[stage] << test_case
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def junit_report(test_cases)
|
51
|
+
Nokogiri::XML::Document.new.tap do |report|
|
52
|
+
test_suite_node = report.create_element('testsuite', name: 'rspec', **collect_stats(test_cases))
|
53
|
+
report.root = test_suite_node
|
54
|
+
|
55
|
+
test_cases.each do |test_case|
|
56
|
+
test_suite_node.add_child(test_case)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def collect_stats(test_cases)
|
62
|
+
stats = {
|
63
|
+
tests: test_cases.size,
|
64
|
+
failures: 0,
|
65
|
+
errors: 0,
|
66
|
+
skipped: 0
|
67
|
+
}
|
68
|
+
|
69
|
+
test_cases.each_with_object(stats) do |test_case, memo|
|
70
|
+
memo[:failures] += 1 unless test_case.search('failure').empty?
|
71
|
+
memo[:errors] += 1 unless test_case.search('error').empty?
|
72
|
+
memo[:skipped] += 1 unless test_case.search('skipped').empty?
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,377 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'nokogiri'
|
4
|
+
require 'active_support/core_ext/enumerable'
|
5
|
+
require 'rubygems/text'
|
6
|
+
require 'active_support/core_ext/integer/time'
|
7
|
+
|
8
|
+
module GitlabQuality
|
9
|
+
module TestTooling
|
10
|
+
module Report
|
11
|
+
# Uses the API to create or update GitLab issues with the results of tests from RSpec report files.
|
12
|
+
# - Uses the API to create or update GitLab issues with the results of tests from RSpec report files.
|
13
|
+
# - Takes the JSON test run reports, e.g. `$CI_PROJECT_DIR/gitlab-qa-run-*/**/rspec-*.json`
|
14
|
+
# - Takes a project where failure issues should be created
|
15
|
+
# - Find issue by title (with test description or test file), then further filter by stack trace, then pick the better-matching one
|
16
|
+
# - Post a note to the latest failed job, e.g. https://gitlab.com/gitlab-org/gitlab/-/issues/408333#note_1361882769
|
17
|
+
# - Update labels
|
18
|
+
class RelateFailureIssue < ReportAsIssue
|
19
|
+
include Concerns::FindSetDri
|
20
|
+
|
21
|
+
DEFAULT_MAX_DIFF_RATIO_FOR_DETECTION = 0.15
|
22
|
+
SPAM_THRESHOLD_FOR_FAILURE_ISSUES = 3
|
23
|
+
FAILURE_STACKTRACE_REGEX = %r{(?:(?:.*Failure/Error:(?<stacktrace>.+))|(?<stacktrace>.+))}m
|
24
|
+
ISSUE_STACKTRACE_REGEX = /### Stack trace\s*(```)#{FAILURE_STACKTRACE_REGEX}(```)/m
|
25
|
+
FAILED_JOB_DESCRIPTION_REGEX = %r{First happened in https?://\S+\.}m
|
26
|
+
FAILED_JOB_NOTE_REGEX = %r{Failed most recently in \D+ pipeline: https?://\S+}
|
27
|
+
NEW_ISSUE_LABELS = Set.new(%w[QA Quality test failure::new priority::2]).freeze
|
28
|
+
IGNORE_EXCEPTIONS = ['Net::ReadTimeout'].freeze
|
29
|
+
SCREENSHOT_IGNORED_ERRORS = ['500 Internal Server Error', 'fabricate_via_api!', 'Error Code 500'].freeze
|
30
|
+
|
31
|
+
MultipleIssuesFound = Class.new(StandardError)
|
32
|
+
|
33
|
+
def initialize(max_diff_ratio: DEFAULT_MAX_DIFF_RATIO_FOR_DETECTION, system_logs: [], **kwargs)
|
34
|
+
super
|
35
|
+
@max_diff_ratio = max_diff_ratio.to_f
|
36
|
+
@system_logs = Dir.glob(system_logs)
|
37
|
+
@issue_type = 'issue'
|
38
|
+
@commented_issue_list = Set.new
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
attr_reader :max_diff_ratio, :system_logs
|
44
|
+
|
45
|
+
def run!
|
46
|
+
puts "Reporting test failures in `#{files.join(',')}` as issues in project `#{project}` via the API at `#{Runtime::Env.gitlab_api_base}`."
|
47
|
+
|
48
|
+
TestResults::Builder.new(files).test_results_per_file do |test_results|
|
49
|
+
puts "=> Reporting tests in #{test_results.path}"
|
50
|
+
|
51
|
+
test_results.each do |test|
|
52
|
+
relate_failure_to_issue(test) if should_report?(test)
|
53
|
+
end
|
54
|
+
|
55
|
+
test_results.write
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def relate_failure_to_issue(test)
|
60
|
+
puts " => Searching issues for test '#{test.name}'..."
|
61
|
+
|
62
|
+
begin
|
63
|
+
issue = test.quarantine? ? find_and_link_issue(test) : find_and_link_issue(test) || create_issue(test)
|
64
|
+
return unless issue
|
65
|
+
|
66
|
+
update_labels(issue, test)
|
67
|
+
rescue MultipleIssuesFound => e
|
68
|
+
warn(e.message)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def find_and_link_issue(test)
|
73
|
+
issue, diff_ratio = find_failure_issue(test)
|
74
|
+
issue_already_commented = issue ? @commented_issue_list.include?(issue.web_url) : nil
|
75
|
+
|
76
|
+
if issue && !issue_already_commented
|
77
|
+
puts " => Found issue #{issue.web_url} for test '#{test.name}' with a diff ratio of #{(diff_ratio * 100).round(2)}%."
|
78
|
+
post_or_update_failed_job_note(issue, test)
|
79
|
+
@commented_issue_list.add(issue.web_url)
|
80
|
+
else
|
81
|
+
puts " => Failure already commented on issue." if issue_already_commented
|
82
|
+
|
83
|
+
return false
|
84
|
+
end
|
85
|
+
|
86
|
+
issue
|
87
|
+
end
|
88
|
+
|
89
|
+
def create_issue(test)
|
90
|
+
similar_issues = pipeline_issues_with_similar_stacktrace(test)
|
91
|
+
|
92
|
+
if similar_issues.size >= SPAM_THRESHOLD_FOR_FAILURE_ISSUES
|
93
|
+
puts " => Similar failure issues have already been opened for same pipeline environment"
|
94
|
+
puts " => Will not create new issue for this failing spec"
|
95
|
+
similar_issues.each do |similar_issue|
|
96
|
+
puts "Please check issue: #{similar_issue.web_url}"
|
97
|
+
gitlab.create_issue_note(iid: similar_issue.iid,
|
98
|
+
note: "This failed job is most likely related: #{test.ci_job_url}")
|
99
|
+
end
|
100
|
+
return
|
101
|
+
end
|
102
|
+
|
103
|
+
issue = super
|
104
|
+
puts "for test '#{test.name}'."
|
105
|
+
|
106
|
+
post_or_update_failed_job_note(issue, test)
|
107
|
+
|
108
|
+
assign_dri(issue, test)
|
109
|
+
|
110
|
+
issue
|
111
|
+
end
|
112
|
+
|
113
|
+
def pipeline_issues_with_similar_stacktrace(test)
|
114
|
+
gitlab.find_issues(options: { state: 'opened', labels: 'QA,failure::new',
|
115
|
+
created_after: past_timestamp(2) }).select do |issue|
|
116
|
+
job_url_from_issue = failed_issue_job_url(issue)
|
117
|
+
next unless pipeline == pipeline_env_from_job_url(job_url_from_issue)
|
118
|
+
|
119
|
+
stack_trace_from_issue = cleaned_stack_trace_from_issue(issue)
|
120
|
+
stack_trace_from_test = cleaned_stacktrace_from_test(test)
|
121
|
+
diff_ratio = compare_stack_traces(stack_trace_from_test, stack_trace_from_issue)
|
122
|
+
diff_ratio < max_diff_ratio
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def failed_issue_job_url(issue)
|
127
|
+
existing_note = existing_failure_note(issue)
|
128
|
+
if existing_note
|
129
|
+
job_url_string = existing_note.body
|
130
|
+
matched = job_url_string.match(FAILED_JOB_NOTE_REGEX)
|
131
|
+
else
|
132
|
+
job_url_string = issue.description
|
133
|
+
matched = job_url_string.match(FAILED_JOB_DESCRIPTION_REGEX)
|
134
|
+
end
|
135
|
+
|
136
|
+
return unless matched
|
137
|
+
|
138
|
+
job_url = matched[0].chop.split.last
|
139
|
+
puts "=> Found failed job url in the issue: #{job_url}"
|
140
|
+
job_url
|
141
|
+
end
|
142
|
+
|
143
|
+
def pipeline_env_from_job_url(job_url)
|
144
|
+
return if job_url.nil?
|
145
|
+
|
146
|
+
if job_url.include?('/quality/')
|
147
|
+
job_url.partition('/quality/').last.partition('/').first
|
148
|
+
else
|
149
|
+
'master'
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
def past_timestamp(hours_ago)
|
154
|
+
timestamp = Time.now - (hours_ago * 60 * 60)
|
155
|
+
timestamp.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
156
|
+
end
|
157
|
+
|
158
|
+
def failure_issues(test)
|
159
|
+
gitlab.find_issues(options: { state: 'opened', labels: 'QA' }).select do |issue|
|
160
|
+
issue_title = issue.title.strip
|
161
|
+
issue_title.include?(test.name) || issue_title.include?(partial_file_path(test.file))
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
def full_stacktrace(test)
|
166
|
+
if test.failures.first['message_lines'].empty? || test.failures.first['message_lines'].instance_of?(String)
|
167
|
+
test.failures.first['message']
|
168
|
+
else
|
169
|
+
test.failures.first['message_lines'].join("\n")
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
def cleaned_stack_trace_from_issue(issue)
|
174
|
+
relevant_issue_stacktrace = find_issue_stacktrace(issue)
|
175
|
+
return unless relevant_issue_stacktrace
|
176
|
+
|
177
|
+
remove_unique_resource_names(relevant_issue_stacktrace)
|
178
|
+
end
|
179
|
+
|
180
|
+
def cleaned_stacktrace_from_test(test)
|
181
|
+
first_test_failure_stacktrace = sanitize_stacktrace(full_stacktrace(test),
|
182
|
+
FAILURE_STACKTRACE_REGEX) || full_stacktrace(test)
|
183
|
+
remove_unique_resource_names(first_test_failure_stacktrace)
|
184
|
+
end
|
185
|
+
|
186
|
+
def compare_stack_traces(stack_trace_first, stack_trace_second)
|
187
|
+
calculate_diff_ratio(stack_trace_first, stack_trace_second)
|
188
|
+
end
|
189
|
+
|
190
|
+
def calculate_diff_ratio(stack_trace_first, stack_trace_second)
|
191
|
+
ld = Class.new.extend(Gem::Text).method(:levenshtein_distance)
|
192
|
+
distance = ld.call(stack_trace_first, stack_trace_second)
|
193
|
+
distance.zero? ? 0.0 : (distance.to_f / stack_trace_first.size).round(3)
|
194
|
+
end
|
195
|
+
|
196
|
+
def find_relevant_failure_issues(test) # rubocop:disable Metrics/AbcSize
|
197
|
+
clean_first_test_failure_stacktrace = cleaned_stacktrace_from_test(test)
|
198
|
+
# Search with the `search` param returns 500 errors, so we filter by ~QA and then filter further in Ruby
|
199
|
+
failure_issues(test).each_with_object({}) do |issue, memo|
|
200
|
+
clean_relevant_issue_stacktrace = cleaned_stack_trace_from_issue(issue)
|
201
|
+
next if clean_relevant_issue_stacktrace.nil?
|
202
|
+
|
203
|
+
diff_ratio = compare_stack_traces(clean_first_test_failure_stacktrace, clean_relevant_issue_stacktrace)
|
204
|
+
if diff_ratio <= max_diff_ratio
|
205
|
+
puts " => [DEBUG] Issue #{issue.web_url} has an acceptable diff ratio of #{(diff_ratio * 100).round(2)}%."
|
206
|
+
# The `Gitlab::ObjectifiedHash` class overrides `#hash` which is used by `Hash#[]=` to compute the hash key.
|
207
|
+
# This leads to a `TypeError Exception: no implicit conversion of Hash into Integer` error, so we convert the object to a hash before using it as a Hash key.
|
208
|
+
# See:
|
209
|
+
# - https://gitlab.com/gitlab-org/gitlab-qa/-/merge_requests/587#note_453336995
|
210
|
+
# - https://github.com/NARKOZ/gitlab/commit/cbdbd1e32623f018a8fae39932a8e3bc4d929abb?_pjax=%23js-repo-pjax-container#r44484494
|
211
|
+
memo[issue.to_h] = diff_ratio
|
212
|
+
else
|
213
|
+
puts " => [DEBUG] Found issue #{issue.web_url} but stacktraces are too different (#{(diff_ratio * 100).round(2)}%).\n"
|
214
|
+
puts " => [DEBUG] Issue stacktrace:\n----------------\n#{clean_relevant_issue_stacktrace}\n----------------\n"
|
215
|
+
puts " => [DEBUG] Failure stacktrace:\n----------------\n#{clean_first_test_failure_stacktrace}\n----------------\n"
|
216
|
+
end
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
def find_issue_stacktrace(issue)
|
221
|
+
issue_stacktrace = sanitize_stacktrace(issue.description, ISSUE_STACKTRACE_REGEX)
|
222
|
+
return issue_stacktrace if issue_stacktrace
|
223
|
+
|
224
|
+
puts " => [DEBUG] Stacktrace couldn't be found for #{issue.web_url}!"
|
225
|
+
end
|
226
|
+
|
227
|
+
def sanitize_stacktrace(stacktrace, regex)
|
228
|
+
stacktrace_match = stacktrace.match(regex)
|
229
|
+
|
230
|
+
if stacktrace_match
|
231
|
+
stacktrace_match[:stacktrace].split('First happened in')[0].gsub(/^\s*#.*$/, '').gsub(/^[[:space:]]+/,
|
232
|
+
'').strip
|
233
|
+
else
|
234
|
+
puts " => [DEBUG] Stacktrace doesn't match the expected regex (#{regex}):\n----------------\n#{stacktrace}\n----------------\n"
|
235
|
+
end
|
236
|
+
end
|
237
|
+
|
238
|
+
def remove_unique_resource_names(stacktrace)
|
239
|
+
stacktrace.gsub(/qa-(test|user)-[a-z0-9-]+/, '<unique-test-resource>').gsub(
|
240
|
+
/(?:-|_)(?:\d+[a-z]|[a-z]+\d)[a-z\d]{4,}/, '<unique-hash>')
|
241
|
+
end
|
242
|
+
|
243
|
+
def find_failure_issue(test)
|
244
|
+
relevant_issues = find_relevant_failure_issues(test)
|
245
|
+
|
246
|
+
return nil if relevant_issues.empty?
|
247
|
+
|
248
|
+
best_matching_issue, smaller_diff_ratio = relevant_issues.min_by { |_, diff_ratio| diff_ratio }
|
249
|
+
|
250
|
+
raise(MultipleIssuesFound, %(Too many issues found for test '#{test.name}' (`#{test.file}`)!)) unless relevant_issues.values.count(smaller_diff_ratio) == 1
|
251
|
+
|
252
|
+
# Re-instantiate a `Gitlab::ObjectifiedHash` object after having converted it to a hash in #find_relevant_failure_issues above.
|
253
|
+
best_matching_issue = Gitlab::ObjectifiedHash.new(best_matching_issue)
|
254
|
+
|
255
|
+
test.failure_issue ||= best_matching_issue.web_url
|
256
|
+
|
257
|
+
[best_matching_issue, smaller_diff_ratio]
|
258
|
+
end
|
259
|
+
|
260
|
+
def new_issue_description(test)
|
261
|
+
super + [
|
262
|
+
"\n### Stack trace",
|
263
|
+
"```\n#{full_stacktrace(test)}\n```",
|
264
|
+
"First happened in #{test.ci_job_url}.",
|
265
|
+
"Related test case: #{test.testcase}.",
|
266
|
+
screenshot_section(test),
|
267
|
+
system_log_errors_section(test)
|
268
|
+
].compact.join("\n\n")
|
269
|
+
end
|
270
|
+
|
271
|
+
def system_log_errors_section(test)
|
272
|
+
correlation_id = test.failures.first['correlation_id']
|
273
|
+
section = ''
|
274
|
+
|
275
|
+
if system_logs.any? && !correlation_id.nil?
|
276
|
+
section = SystemLogs::SystemLogsFormatter.new(
|
277
|
+
system_logs,
|
278
|
+
correlation_id
|
279
|
+
).system_logs_summary_markdown
|
280
|
+
end
|
281
|
+
|
282
|
+
puts " => No system logs or correlation id provided, skipping this section in issue description" if section.empty?
|
283
|
+
|
284
|
+
section
|
285
|
+
end
|
286
|
+
|
287
|
+
def new_issue_labels(test)
|
288
|
+
up_to_date_labels(test: test, new_labels: NEW_ISSUE_LABELS)
|
289
|
+
end
|
290
|
+
|
291
|
+
def up_to_date_labels(test:, issue: nil, new_labels: Set.new)
|
292
|
+
super << pipeline_name_label
|
293
|
+
end
|
294
|
+
|
295
|
+
def post_or_update_failed_job_note(issue, test)
|
296
|
+
current_note = "Failed most recently in #{pipeline} pipeline: #{test.ci_job_url}"
|
297
|
+
existing_note = existing_failure_note(issue)
|
298
|
+
|
299
|
+
if existing_note
|
300
|
+
gitlab.edit_issue_note(issue_iid: issue.iid, note_id: existing_note.id, note: current_note)
|
301
|
+
else
|
302
|
+
gitlab.create_issue_note(iid: issue.iid, note: current_note)
|
303
|
+
end
|
304
|
+
|
305
|
+
puts " => Linked #{test.ci_job_url} to #{issue.web_url}."
|
306
|
+
end
|
307
|
+
|
308
|
+
def new_issue_title(test)
|
309
|
+
"Failure in #{super}"
|
310
|
+
end
|
311
|
+
|
312
|
+
def existing_failure_note(issue)
|
313
|
+
gitlab.find_issue_notes(iid: issue.iid)&.find do |note|
|
314
|
+
note.body.include?('Failed most recently in')
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
def screenshot_section(test)
|
319
|
+
return unless test.screenshot?
|
320
|
+
|
321
|
+
failure = full_stacktrace(test)
|
322
|
+
return if SCREENSHOT_IGNORED_ERRORS.any? { |e| failure.include?(e) }
|
323
|
+
|
324
|
+
relative_url = gitlab.upload_file(file_fullpath: test.failure_screenshot)
|
325
|
+
return unless relative_url
|
326
|
+
|
327
|
+
"### Screenshot: #{relative_url.markdown}"
|
328
|
+
end
|
329
|
+
|
330
|
+
def assign_dri(issue, test)
|
331
|
+
if test.product_group?
|
332
|
+
dri = set_dri_via_group(test.product_group, test)
|
333
|
+
dri_id = gitlab.find_user_id(username: dri)
|
334
|
+
gitlab.edit_issue(iid: issue.iid, options: { assignee_id: dri_id, due_date: Date.today + 1.month })
|
335
|
+
puts " => Assigning #{dri} as DRI for the issue."
|
336
|
+
else
|
337
|
+
puts " => No product group metadata found for test '#{test.name}'"
|
338
|
+
end
|
339
|
+
end
|
340
|
+
|
341
|
+
# Checks if a test failure should be reported.
|
342
|
+
#
|
343
|
+
# @return [TrueClass|FalseClass] false if the test was skipped or failed because of a transient error that can be ignored.
|
344
|
+
# Otherwise returns true.
|
345
|
+
def should_report?(test)
|
346
|
+
return false if test.failures.empty?
|
347
|
+
|
348
|
+
if test.report.key?('exceptions')
|
349
|
+
reason = ignore_failure_reason(test.report['exceptions'])
|
350
|
+
|
351
|
+
if reason
|
352
|
+
puts "Failure reporting skipped because #{reason}"
|
353
|
+
|
354
|
+
return false
|
355
|
+
end
|
356
|
+
end
|
357
|
+
|
358
|
+
true
|
359
|
+
end
|
360
|
+
|
361
|
+
# Determine any reason to ignore a failure.
|
362
|
+
#
|
363
|
+
# @param [Array<Hash>] exceptions the exceptions associated with the failure.
|
364
|
+
# @return [String] the reason to ignore the exceptions, or `nil` if any exceptions should not be ignored.
|
365
|
+
def ignore_failure_reason(exceptions)
|
366
|
+
exception_classes = exceptions
|
367
|
+
.filter_map { |exception| exception['class'] if IGNORE_EXCEPTIONS.include?(exception['class']) }
|
368
|
+
.compact
|
369
|
+
return if exception_classes.empty? || exception_classes.size < exceptions.size
|
370
|
+
|
371
|
+
msg = exception_classes.many? ? 'the errors were' : 'the error was'
|
372
|
+
"#{msg} #{exception_classes.join(', ')}"
|
373
|
+
end
|
374
|
+
end
|
375
|
+
end
|
376
|
+
end
|
377
|
+
end
|
@@ -0,0 +1,134 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'set'
|
4
|
+
|
5
|
+
module GitlabQuality
|
6
|
+
module TestTooling
|
7
|
+
module Report
|
8
|
+
class ReportAsIssue
|
9
|
+
include Concerns::Utils
|
10
|
+
|
11
|
+
def initialize(token:, input_files:, project: nil, dry_run: false, **_kwargs)
|
12
|
+
@project = project
|
13
|
+
@gitlab = (dry_run ? GitlabIssueDryClient : GitlabIssueClient).new(token: token, project: project)
|
14
|
+
@files = Array(input_files)
|
15
|
+
end
|
16
|
+
|
17
|
+
def invoke!
|
18
|
+
validate_input!
|
19
|
+
|
20
|
+
run!
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
attr_reader :gitlab, :files, :project, :issue_type
|
26
|
+
|
27
|
+
def run!
|
28
|
+
raise NotImplementedError
|
29
|
+
end
|
30
|
+
|
31
|
+
def failed_test_hash(test)
|
32
|
+
OpenSSL::Digest::SHA256.hexdigest(test.file + test.name)
|
33
|
+
end
|
34
|
+
|
35
|
+
def new_issue_description(test)
|
36
|
+
<<~DESCRIPTION
|
37
|
+
### Test metadata (don't modify)
|
38
|
+
|
39
|
+
| Field | Value |
|
40
|
+
| ------ | ------ |
|
41
|
+
| File | `#{test.file}` |
|
42
|
+
| Description | `#{test.name}` |
|
43
|
+
| Hash | `#{failed_test_hash(test)}` |
|
44
|
+
DESCRIPTION
|
45
|
+
end
|
46
|
+
|
47
|
+
def new_issue_labels(_test)
|
48
|
+
[]
|
49
|
+
end
|
50
|
+
|
51
|
+
def validate_input!
|
52
|
+
assert_project!
|
53
|
+
assert_input_files!(files)
|
54
|
+
gitlab.assert_user_permission!
|
55
|
+
end
|
56
|
+
|
57
|
+
def assert_project!
|
58
|
+
return if project
|
59
|
+
|
60
|
+
abort "Please provide a valid project ID or path with the `-p/--project` option!"
|
61
|
+
end
|
62
|
+
|
63
|
+
def assert_input_files!(files)
|
64
|
+
return if Dir.glob(files).any?
|
65
|
+
|
66
|
+
abort "Please provide valid JUnit report files. No files were found matching `#{files.join(',')}`"
|
67
|
+
end
|
68
|
+
|
69
|
+
def create_issue(test)
|
70
|
+
issue = gitlab.create_issue(
|
71
|
+
title: title_from_test(test),
|
72
|
+
description: new_issue_description(test),
|
73
|
+
labels: new_issue_labels(test).to_a,
|
74
|
+
issue_type: issue_type
|
75
|
+
)
|
76
|
+
|
77
|
+
new_link = issue_type == 'test_case' ? issue.web_url.sub('/issues/', '/quality/test_cases/') : issue.web_url
|
78
|
+
|
79
|
+
puts "Created new #{issue_type}: #{new_link}"
|
80
|
+
|
81
|
+
issue
|
82
|
+
end
|
83
|
+
|
84
|
+
def issue_labels(issue)
|
85
|
+
issue&.labels&.to_set || Set.new
|
86
|
+
end
|
87
|
+
|
88
|
+
def update_labels(issue, test, new_labels = Set.new)
|
89
|
+
labels = up_to_date_labels(test: test, issue: issue, new_labels: new_labels)
|
90
|
+
|
91
|
+
return if issue_labels(issue) == labels
|
92
|
+
|
93
|
+
gitlab.edit_issue(iid: issue.iid, options: { labels: labels.to_a })
|
94
|
+
end
|
95
|
+
|
96
|
+
def up_to_date_labels(test:, issue: nil, new_labels: Set.new)
|
97
|
+
labels = issue_labels(issue)
|
98
|
+
labels |= new_labels
|
99
|
+
ee_test?(test) ? labels << 'Enterprise Edition' : labels.delete('Enterprise Edition')
|
100
|
+
|
101
|
+
if test.quarantine?
|
102
|
+
labels << 'quarantine'
|
103
|
+
labels << "quarantine::#{test.quarantine_type}"
|
104
|
+
else
|
105
|
+
labels.delete_if { |label| label.include?('quarantine') }
|
106
|
+
end
|
107
|
+
|
108
|
+
labels
|
109
|
+
end
|
110
|
+
|
111
|
+
def pipeline_name_label
|
112
|
+
case pipeline
|
113
|
+
when 'production'
|
114
|
+
'found:gitlab.com'
|
115
|
+
when 'canary', 'staging'
|
116
|
+
"found:#{pipeline}.gitlab.com"
|
117
|
+
when 'staging-canary'
|
118
|
+
"found:canary.staging.gitlab.com"
|
119
|
+
when 'preprod'
|
120
|
+
'found:pre.gitlab.com'
|
121
|
+
when 'nightly', Runtime::Env.default_branch, 'staging-ref', 'release'
|
122
|
+
"found:#{pipeline}"
|
123
|
+
else
|
124
|
+
raise "No `found:*` label for the `#{pipeline}` pipeline!"
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
def ee_test?(test)
|
129
|
+
test.file =~ %r{features/ee/(api|browser_ui)}
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitlabQuality
|
4
|
+
module TestTooling
|
5
|
+
module Report
|
6
|
+
# Uses the API to:
|
7
|
+
# - create or update test cases
|
8
|
+
# - create or update issues
|
9
|
+
# based on the results of tests from RSpec run result files.
|
10
|
+
class ReportResults < ReportAsIssue
|
11
|
+
attr_accessor :testcase_project_reporter, :results_issue_project_reporter, :files, :test_case_project,
|
12
|
+
:results_issue_project, :gitlab
|
13
|
+
|
14
|
+
def initialize(
|
15
|
+
token:, input_files:, test_case_project: nil, results_issue_project: nil, dry_run: false,
|
16
|
+
**kwargs)
|
17
|
+
@testcase_project_reporter = GitlabQuality::TestTooling::Report::ResultsInTestCases.new(
|
18
|
+
token: token, input_files: input_files, project: test_case_project, dry_run: dry_run, **kwargs)
|
19
|
+
@results_issue_project_reporter = GitlabQuality::TestTooling::Report::ResultsInIssues.new(
|
20
|
+
token: token, input_files: input_files, project: results_issue_project, dry_run: dry_run, **kwargs)
|
21
|
+
@test_case_project = test_case_project
|
22
|
+
@results_issue_project = results_issue_project
|
23
|
+
@files = Array(input_files)
|
24
|
+
@gitlab = testcase_project_reporter.gitlab
|
25
|
+
end
|
26
|
+
|
27
|
+
def invoke!
|
28
|
+
validate_input!
|
29
|
+
|
30
|
+
run!
|
31
|
+
end
|
32
|
+
|
33
|
+
def validate_input!
|
34
|
+
assert_input_files!(files)
|
35
|
+
gitlab.assert_user_permission!
|
36
|
+
end
|
37
|
+
|
38
|
+
def assert_input_files!(files)
|
39
|
+
return if Dir.glob(files).any?
|
40
|
+
|
41
|
+
abort "Please provide valid JUnit report files. No files were found matching `#{files.join(',')}`"
|
42
|
+
end
|
43
|
+
|
44
|
+
# rubocop:disable Metrics/AbcSize
|
45
|
+
def run!
|
46
|
+
puts "Reporting test results in `#{files.join(',')}` as test cases in project `#{test_case_project}` " \
|
47
|
+
"and issues in project `#{results_issue_project}` via the API at `#{Runtime::Env.gitlab_api_base}`."
|
48
|
+
|
49
|
+
TestResults::Builder.new(files).test_results_per_file do |test_results|
|
50
|
+
puts "Reporting tests in #{test_results.path}"
|
51
|
+
|
52
|
+
test_results.each do |test|
|
53
|
+
next if test.file.include?('/features/sanity/') || test.skipped
|
54
|
+
|
55
|
+
puts "Reporting test: #{test.file} | #{test.name}\n"
|
56
|
+
|
57
|
+
report_test(test)
|
58
|
+
end
|
59
|
+
|
60
|
+
test_results.write
|
61
|
+
end
|
62
|
+
end
|
63
|
+
# rubocop:enable Metrics/AbcSize
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
def report_test(test)
|
68
|
+
testcase = testcase_project_reporter.find_or_create_testcase(test)
|
69
|
+
# The API returns the test case with an issue URL since it is technically a type of issue.
|
70
|
+
# This updates the URL to a valid test case link.
|
71
|
+
test.testcase = testcase.web_url.sub('/issues/', '/quality/test_cases/')
|
72
|
+
|
73
|
+
issue, is_new = results_issue_project_reporter.get_related_issue(testcase, test)
|
74
|
+
|
75
|
+
testcase_project_reporter.add_issue_link_to_testcase(testcase, issue, test) if is_new
|
76
|
+
|
77
|
+
testcase_project_reporter.update_testcase(testcase, test)
|
78
|
+
results_issue_project_reporter.update_issue(issue, test)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|