gitlab_quality-test_tooling 0.1.0 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +9 -4
  3. data/Gemfile.lock +1 -1
  4. data/Guardfile +0 -22
  5. data/README.md +150 -9
  6. data/exe/generate-test-session +50 -0
  7. data/exe/post-to-slack +58 -0
  8. data/exe/prepare-stage-reports +38 -0
  9. data/exe/relate-failure-issue +59 -0
  10. data/exe/report-results +56 -0
  11. data/exe/update-screenshot-paths +38 -0
  12. data/lib/gitlab_quality/test_tooling/gitlab_issue_client.rb +194 -0
  13. data/lib/gitlab_quality/test_tooling/gitlab_issue_dry_client.rb +26 -0
  14. data/lib/gitlab_quality/test_tooling/report/concerns/find_set_dri.rb +51 -0
  15. data/lib/gitlab_quality/test_tooling/report/concerns/results_reporter.rb +75 -0
  16. data/lib/gitlab_quality/test_tooling/report/concerns/utils.rb +49 -0
  17. data/lib/gitlab_quality/test_tooling/report/generate_test_session.rb +275 -0
  18. data/lib/gitlab_quality/test_tooling/report/prepare_stage_reports.rb +78 -0
  19. data/lib/gitlab_quality/test_tooling/report/relate_failure_issue.rb +377 -0
  20. data/lib/gitlab_quality/test_tooling/report/report_as_issue.rb +134 -0
  21. data/lib/gitlab_quality/test_tooling/report/report_results.rb +83 -0
  22. data/lib/gitlab_quality/test_tooling/report/results_in_issues.rb +130 -0
  23. data/lib/gitlab_quality/test_tooling/report/results_in_testcases.rb +113 -0
  24. data/lib/gitlab_quality/test_tooling/report/update_screenshot_path.rb +81 -0
  25. data/lib/gitlab_quality/test_tooling/runtime/env.rb +113 -0
  26. data/lib/gitlab_quality/test_tooling/runtime/logger.rb +92 -0
  27. data/lib/gitlab_quality/test_tooling/runtime/token_finder.rb +44 -0
  28. data/lib/gitlab_quality/test_tooling/slack/post_to_slack.rb +36 -0
  29. data/lib/gitlab_quality/test_tooling/summary_table.rb +41 -0
  30. data/lib/gitlab_quality/test_tooling/support/http_request.rb +34 -0
  31. data/lib/gitlab_quality/test_tooling/system_logs/finders/json_log_finder.rb +65 -0
  32. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/api_log_finder.rb +21 -0
  33. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/application_log_finder.rb +21 -0
  34. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/exception_log_finder.rb +21 -0
  35. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/graphql_log_finder.rb +21 -0
  36. data/lib/gitlab_quality/test_tooling/system_logs/log_types/log.rb +38 -0
  37. data/lib/gitlab_quality/test_tooling/system_logs/log_types/rails/api_log.rb +34 -0
  38. data/lib/gitlab_quality/test_tooling/system_logs/log_types/rails/application_log.rb +27 -0
  39. data/lib/gitlab_quality/test_tooling/system_logs/log_types/rails/exception_log.rb +23 -0
  40. data/lib/gitlab_quality/test_tooling/system_logs/log_types/rails/graphql_log.rb +30 -0
  41. data/lib/gitlab_quality/test_tooling/system_logs/shared_fields.rb +29 -0
  42. data/lib/gitlab_quality/test_tooling/system_logs/system_logs_formatter.rb +65 -0
  43. data/lib/gitlab_quality/test_tooling/test_results/base_test_results.rb +39 -0
  44. data/lib/gitlab_quality/test_tooling/test_results/builder.rb +35 -0
  45. data/lib/gitlab_quality/test_tooling/test_results/j_unit_test_results.rb +27 -0
  46. data/lib/gitlab_quality/test_tooling/test_results/json_test_results.rb +29 -0
  47. data/lib/gitlab_quality/test_tooling/test_results/test_result.rb +184 -0
  48. data/lib/gitlab_quality/test_tooling/version.rb +1 -1
  49. data/lib/gitlab_quality/test_tooling.rb +11 -2
  50. 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