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.
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