gitlab_quality-test_tooling 1.21.1 → 1.23.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3bd25aa86cb58d8b69f852509893b2d7f28a183474d6b4ac96732d8bd3efca23
4
- data.tar.gz: f17357f3f9e1c97089ac675c918682341978a60ac40cace79557722c2ce4cdff
3
+ metadata.gz: c63a7a25d0e3a05f515954353898bb8d0dcb9e7a90634087e738f5cb16174c57
4
+ data.tar.gz: 386833833dc0fd5cdc6334d3d613a38544fbc885c5ccb396070614f0e889555f
5
5
  SHA512:
6
- metadata.gz: a2d8710d330b9dd79a54ff78de39e15e76b014ce70de89791991723a6a272222edda6a751bb0449ba1a820163713046e617c9d45d4fa6a895f0eb4f8d86889bc
7
- data.tar.gz: 2eda2f409021c5d8f8c710a66294266fcda7a8ef46d3580ab680175718d8468c62b4fea6c375cfcb51cd1fe899f9c83918962f767b9c534916cfcb68fab1c676
6
+ metadata.gz: bb13a1c06a626b1152115262e0606480e78eb9aaafd6ed5e673cd72d6b1a268ab7659c020dbab743ebb872e71c1332905319d0ed6970cbde8bf0168c208fa2c3
7
+ data.tar.gz: 40652c63561438f37f5610e5b22f93c6a0404c53d4dc65b17713bbf5469bf769dd13691361f02f5aa8229aab706f7e349db6abf663cf45745aaa040456461db7
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- gitlab_quality-test_tooling (1.21.1)
4
+ gitlab_quality-test_tooling (1.23.0)
5
5
  activesupport (>= 6.1, < 7.2)
6
6
  amatch (~> 0.4.1)
7
7
  gitlab (~> 4.19)
@@ -328,4 +328,4 @@ DEPENDENCIES
328
328
  webmock (= 3.7.0)
329
329
 
330
330
  BUNDLED WITH
331
- 2.5.6
331
+ 2.5.4
data/README.md CHANGED
@@ -156,6 +156,27 @@ Usage: exe/knapsack-report-issues [options]
156
156
  -h, --help Show the usage
157
157
  ```
158
158
 
159
+ ### `exe/failed-test-issues`
160
+
161
+ ```shell
162
+ Purpose: Relate test failures to failure issues from RSpec report files (JSON or JUnit XML)
163
+ Usage: exe/failed-test-issues [options]
164
+ -i, --input-files INPUT_FILES RSpec report files (JSON or JUnit XML)
165
+ -p, --project PROJECT Can be an integer or a group/project string
166
+ -t, --token TOKEN A valid access token with `api` scope and Maintainer permission in PROJECT
167
+ --max-diff-ratio MAX_DIFF_RATO
168
+ Max stacktrace diff ratio for failure issues detection
169
+ -r RELATED_ISSUES_FILE, The file path for the related issues
170
+ --related-issues-file
171
+ --base-issue-labels BASE_ISSUE_LABELS
172
+ Labels to add to new failure issues
173
+ --exclude-labels-for-search EXCLUDE_LABELS_FOR_SEARCH
174
+ Labels to exclude when searching for existing issues
175
+ --dry-run Perform a dry-run (don't create or update issues)
176
+ -v, --version Show the version
177
+ -h, --help Show the usage
178
+ ```
179
+
159
180
  ### `exe/flaky-test-issues`
160
181
 
161
182
  ```shell
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "optparse"
6
+
7
+ require_relative "../lib/gitlab_quality/test_tooling"
8
+
9
+ params = {}
10
+
11
+ options = OptionParser.new do |opts|
12
+ opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
13
+
14
+ opts.on('-i', '--input-files INPUT_FILES', String, 'RSpec report files (JSON or JUnit XML)') do |input_files|
15
+ params[:input_files] = input_files
16
+ end
17
+
18
+ opts.on('-p', '--project PROJECT', String, 'Can be an integer or a group/project string') do |project|
19
+ params[:project] = project
20
+ end
21
+
22
+ opts.on('-t', '--token TOKEN', String, 'A valid access token with `api` scope and Maintainer permission in PROJECT') do |token|
23
+ params[:token] = token
24
+ end
25
+
26
+ opts.on('--max-diff-ratio MAX_DIFF_RATO', Float, 'Max stacktrace diff ratio for failure issues detection') do |max_diff_ratio|
27
+ params[:max_diff_ratio] = max_diff_ratio
28
+ end
29
+
30
+ opts.on('-r', '--related-issues-file RELATED_ISSUES_FILE', String, 'The file path for the related issues') do |related_issues_file|
31
+ params[:related_issues_file] = related_issues_file
32
+ end
33
+
34
+ opts.on('--base-issue-labels BASE_ISSUE_LABELS', String,
35
+ 'Labels to add to new failure issues') do |base_issue_labels|
36
+ params[:base_issue_labels] = base_issue_labels.split(',')
37
+ end
38
+
39
+ opts.on('--exclude-labels-for-search EXCLUDE_LABELS_FOR_SEARCH', String,
40
+ 'Labels to exclude when searching for existing issues') do |exclude_labels_for_search|
41
+ params[:exclude_labels_for_search] = exclude_labels_for_search.split(',')
42
+ end
43
+
44
+ opts.on('--dry-run', "Perform a dry-run (don't create or update issues)") do
45
+ params[:dry_run] = true
46
+ end
47
+
48
+ opts.on_tail('-v', '--version', 'Show the version') do
49
+ require_relative "../lib/gitlab_quality/test_tooling/version"
50
+ puts "#{$PROGRAM_NAME} : #{GitlabQuality::TestTooling::VERSION}"
51
+ exit
52
+ end
53
+
54
+ opts.on_tail('-h', '--help', 'Show the usage') do
55
+ puts "Purpose: Relate test failures to failure issues from RSpec report files (JSON or JUnit XML)"
56
+ puts opts
57
+ exit
58
+ end
59
+
60
+ opts.parse(ARGV)
61
+ end
62
+
63
+ if params.any?
64
+ GitlabQuality::TestTooling::Report::FailedTestIssue.new(**params).invoke!
65
+ else
66
+ puts options
67
+ exit 1
68
+ end
@@ -13,6 +13,10 @@ module Gitlab
13
13
  get("/projects/#{url_encode(project)}/issues/#{issue_id}/discussions", query: options)
14
14
  end
15
15
 
16
+ def create_issue_discussion(project, issue_iid, options = {})
17
+ post("/projects/#{url_encode(project)}/issues/#{issue_iid}/discussions", query: options)
18
+ end
19
+
16
20
  def add_note_to_issue_discussion_as_thread(project, issue_id, discussion_id, options = {})
17
21
  post("/projects/#{url_encode(project)}/issues/#{issue_id}/discussions/#{discussion_id}/notes", query: options)
18
22
  end
@@ -93,9 +97,15 @@ module GitlabQuality
93
97
  end
94
98
  end
95
99
 
96
- def add_note_to_issue_discussion_as_thread(iid:, discussion_id:, body:)
100
+ def create_issue_discussion(iid:, note:)
101
+ handle_gitlab_client_exceptions do
102
+ client.create_issue_discussion(project, iid, body: note)
103
+ end
104
+ end
105
+
106
+ def add_note_to_issue_discussion_as_thread(iid:, discussion_id:, note:)
97
107
  handle_gitlab_client_exceptions do
98
- client.add_note_to_issue_discussion_as_thread(project, iid, discussion_id, body: body)
108
+ client.add_note_to_issue_discussion_as_thread(project, iid, discussion_id, body: note)
99
109
  end
100
110
  end
101
111
 
@@ -23,8 +23,12 @@ module GitlabQuality
23
23
  puts "The following note would have been edited on #{project}##{issue_iid} (note #{note_id}) issue: #{note}"
24
24
  end
25
25
 
26
- def add_note_to_issue_discussion_as_thread(iid:, discussion_id:, body:)
27
- puts "The following discussion note would have been posted on #{project}##{iid} (discussion #{discussion_id}) issue: #{body}"
26
+ def create_issue_discussion(iid:, note:)
27
+ puts "The following discussion would have been posted on #{project}##{iid} issue: #{note}"
28
+ end
29
+
30
+ def add_note_to_issue_discussion_as_thread(iid:, discussion_id:, note:)
31
+ puts "The following discussion note would have been posted on #{project}##{iid} (discussion #{discussion_id}) issue: #{note}"
28
32
  end
29
33
 
30
34
  def upload_file(file_fullpath:)
@@ -12,11 +12,57 @@ module GitlabQuality
12
12
  REPORT_ITEM_REGEX = /^1\. \d{4}-\d{2}-\d{2}: #{JOB_URL_REGEX} \((?<pipeline_url>\S+)\)/
13
13
  LATEST_REPORTS_TO_SHOW = 10
14
14
 
15
+ class ReportsList
16
+ def initialize(preserved_content:, section_header:, reports:, extra_content:)
17
+ @preserved_content = preserved_content
18
+ @section_header = section_header
19
+ @reports = reports
20
+ @extra_content = extra_content
21
+ end
22
+
23
+ def self.report_list_item(test, item_extra_content: nil)
24
+ "1. #{Time.new.utc.strftime('%F')}: #{test.ci_job_url} (#{ENV.fetch('CI_PIPELINE_URL', 'pipeline url is missing')}) #{item_extra_content}".strip
25
+ end
26
+
27
+ def reports_count
28
+ reports.size
29
+ end
30
+
31
+ def to_s
32
+ [
33
+ preserved_content,
34
+ "#{section_header} (#{reports_count})",
35
+ reports_list(reports),
36
+ extra_content
37
+ ].reject(&:blank?).compact.join("\n\n")
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :preserved_content, :section_header, :reports, :extra_content
43
+
44
+ def reports_list(reports)
45
+ sorted_reports = reports.sort.reverse
46
+
47
+ if sorted_reports.size > LATEST_REPORTS_TO_SHOW
48
+ [
49
+ "Last 10 reports:",
50
+ sorted_reports[...LATEST_REPORTS_TO_SHOW].join("\n"),
51
+ "<details><summary>See #{sorted_reports.size - LATEST_REPORTS_TO_SHOW} more reports</summary>",
52
+ sorted_reports[LATEST_REPORTS_TO_SHOW..].join("\n"),
53
+ "</details>"
54
+ ].join("\n\n")
55
+ else
56
+ sorted_reports.join("\n")
57
+ end
58
+ end
59
+ end
60
+
15
61
  def initial_reports_section(test)
16
62
  <<~REPORTS
17
63
  ### Reports (1)
18
64
 
19
- #{report_list_item(test)}
65
+ #{ReportsList.report_list_item(test)}
20
66
  REPORTS
21
67
  end
22
68
 
@@ -27,14 +73,14 @@ module GitlabQuality
27
73
  item_extra_content: nil,
28
74
  reports_extra_content: nil)
29
75
  preserved_content = current_reports_content.split(reports_section_header).first&.strip
30
- reports = report_lines(current_reports_content) + [report_list_item(test, item_extra_content: item_extra_content)]
31
-
32
- [
33
- preserved_content,
34
- "#{reports_section_header} (#{reports.size})",
35
- reports_list(reports),
36
- reports_extra_content
37
- ].reject(&:blank?).compact.join("\n\n")
76
+ reports = report_lines(current_reports_content) + [ReportsList.report_list_item(test, item_extra_content: item_extra_content)]
77
+
78
+ ReportsList.new(
79
+ preserved_content: preserved_content,
80
+ section_header: reports_section_header,
81
+ reports: reports,
82
+ extra_content: reports_extra_content
83
+ )
38
84
  end
39
85
 
40
86
  def failed_issue_job_url(issue)
@@ -55,40 +101,12 @@ module GitlabQuality
55
101
  content.lines.grep(REPORT_ITEM_REGEX).map(&:strip)
56
102
  end
57
103
 
58
- def reports_list(reports)
59
- sorted_reports = reports.sort.reverse
60
-
61
- if sorted_reports.size > LATEST_REPORTS_TO_SHOW
62
- [
63
- "Last 10 reports:",
64
- sorted_reports[...LATEST_REPORTS_TO_SHOW].join("\n"),
65
- "<details><summary>See #{sorted_reports.size - LATEST_REPORTS_TO_SHOW} more reports</summary>",
66
- sorted_reports[LATEST_REPORTS_TO_SHOW..].join("\n"),
67
- "</details>"
68
- ].join("\n\n")
69
- else
70
- sorted_reports.join("\n")
71
- end
72
- end
73
-
74
- def report_list_item(test, item_extra_content: nil)
75
- "1. #{Time.new.utc.strftime('%F')}: #{test.ci_job_url} (#{ENV.fetch('CI_PIPELINE_URL', 'pipeline url is missing')}) #{item_extra_content}".strip
76
- end
77
-
78
104
  def job_urls_from_description(issue_description, regex)
79
105
  issue_description.lines.filter_map do |line|
80
106
  match = line.match(regex)
81
107
  match[:job_url] if match
82
108
  end
83
109
  end
84
-
85
- def test_captures_to_report_items(test_captures)
86
- test_captures.map do |ci_job_url, _, _|
87
- report_list_item(GitlabQuality::TestTooling::TestResult::JsonTestResult.new(
88
- 'ci_job_url' => ci_job_url
89
- ))
90
- end
91
- end
92
110
  end
93
111
  end
94
112
  end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabQuality
4
+ module TestTooling
5
+ module Report
6
+ # Uses the API to create GitLab issues for any failed test coming from JSON test reports.
7
+ #
8
+ # - Takes the JSON test reports like rspec-*.json
9
+ # - Takes a project where failed test issues should be created
10
+ # - For every passed test in the report:
11
+ # - Find issue by test hash or create a new issue if no issue was found
12
+ # - Add a failure report in the "Failure reports" note
13
+ class FailedTestIssue < ReportAsIssue
14
+ include Concerns::GroupAndCategoryLabels
15
+ include Concerns::IssueReports
16
+
17
+ IDENTITY_LABELS = ['test', 'automation:bot-authored'].freeze
18
+ NEW_ISSUE_LABELS = Set.new(['type::maintenance', 'failure::new', 'priority::3', 'severity::3', *IDENTITY_LABELS]).freeze
19
+ SEARCH_LABELS = ['test'].freeze
20
+ FOUND_IN_MR_LABEL = '~"found:in MR"'
21
+ FOUND_IN_MASTER_LABEL = '~"found:master"'
22
+ REPORTS_DISCUSSION_HEADER = '### Failure reports'
23
+ REPORT_SECTION_HEADER = '#### Failure reports'
24
+
25
+ FAILURE_STACKTRACE_REGEX = %r{(?:(?:.*Failure/Error:(?<stacktrace>.+))|(?<stacktrace>.+))}m
26
+ ISSUE_STACKTRACE_REGEX = /##### Stack trace\s*(```)#{FAILURE_STACKTRACE_REGEX}(```)\n*/m
27
+ DEFAULT_MAX_DIFF_RATIO_FOR_DETECTION = 0.15
28
+
29
+ MultipleNotesFound = Class.new(StandardError)
30
+
31
+ def initialize(
32
+ token:,
33
+ input_files:,
34
+ base_issue_labels: nil,
35
+ dry_run: false,
36
+ project: nil,
37
+ max_diff_ratio: DEFAULT_MAX_DIFF_RATIO_FOR_DETECTION,
38
+ **_kwargs)
39
+ super(token: token, input_files: input_files, project: project, dry_run: dry_run)
40
+
41
+ @base_issue_labels = Set.new(base_issue_labels)
42
+ @max_diff_ratio = max_diff_ratio.to_f
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :base_issue_labels, :max_diff_ratio
48
+
49
+ def run!
50
+ puts "Reporting failed tests in `#{files.join(',')}` as issues in project `#{project}` via the API at `#{Runtime::Env.gitlab_api_base}`."
51
+
52
+ TestResults::Builder.new(files).test_results_per_file do |test_results|
53
+ puts "=> Reporting #{test_results.count} tests in #{test_results.path}"
54
+
55
+ process_test_results(test_results)
56
+ end
57
+ end
58
+
59
+ def process_test_results(test_results)
60
+ test_results.each do |test|
61
+ next unless test_is_applicable?(test)
62
+
63
+ puts " => Reporting failure for test '#{test.name}'..."
64
+
65
+ issues = find_issues_by_hash(test_hash(test), state: 'opened', labels: SEARCH_LABELS)
66
+ issues << create_issue(test) if issues.empty?
67
+
68
+ update_reports(issues, test)
69
+ collect_issues(test, issues)
70
+ end
71
+ end
72
+
73
+ def test_is_applicable?(test)
74
+ test.status == 'failed'
75
+ end
76
+
77
+ def up_to_date_labels(test:, issue: nil, new_labels: Set.new)
78
+ (base_issue_labels + super).to_a
79
+ end
80
+
81
+ def update_reports(issues, test)
82
+ issues.each do |issue|
83
+ puts " => Adding the failed test to the existing issue: #{issue.web_url}"
84
+ add_report_to_issue(issue: issue, test: test, related_issues: (issues - [issue]))
85
+ end
86
+ end
87
+
88
+ def add_report_to_issue(issue:, test:, related_issues:) # rubocop:disable Metrics/AbcSize:
89
+ reports_discussion = find_or_create_reports_discussion(issue: issue)
90
+ current_reports_note = find_failure_discussion_note(issue: issue, test: test, reports_discussion: reports_discussion)
91
+ new_reports_list = add_report_for_test(current_reports_content: current_reports_note&.body.to_s, test: test)
92
+
93
+ note_body = [
94
+ new_reports_list.to_s,
95
+ identity_labels_quick_action,
96
+ relate_issues_quick_actions(related_issues)
97
+ ].join("\n")
98
+
99
+ if current_reports_note
100
+ gitlab.edit_issue_note(
101
+ issue_iid: issue.iid,
102
+ note_id: current_reports_note.id,
103
+ note: note_body
104
+ )
105
+ else
106
+ gitlab.add_note_to_issue_discussion_as_thread(
107
+ iid: issue.iid,
108
+ discussion_id: reports_discussion.id,
109
+ note: note_body
110
+ )
111
+ end
112
+ rescue MultipleNotesFound => e
113
+ warn(e.message)
114
+ end
115
+
116
+ def find_or_create_reports_discussion(issue:)
117
+ reports_discussion = existing_reports_discussion(issue: issue)
118
+ return reports_discussion if reports_discussion
119
+
120
+ gitlab.create_issue_discussion(iid: issue.iid, note: REPORTS_DISCUSSION_HEADER)
121
+ end
122
+
123
+ def existing_reports_discussion(issue:)
124
+ gitlab.find_issue_discussions(iid: issue.iid).find do |discussion|
125
+ next if discussion.individual_note
126
+ next unless discussion.notes.first
127
+
128
+ discussion.notes.first.body.start_with?(REPORTS_DISCUSSION_HEADER)
129
+ end
130
+ end
131
+
132
+ def find_failure_discussion_note(issue:, test:, reports_discussion:)
133
+ return unless reports_discussion
134
+
135
+ relevant_notes = find_relevant_failure_discussion_note(issue: issue, test: test, reports_discussion: reports_discussion)
136
+ return if relevant_notes.empty?
137
+
138
+ best_matching_note, smaller_diff_ratio = relevant_notes.min_by { |_, diff_ratio| diff_ratio }
139
+
140
+ raise(MultipleNotesFound, %(Too many issues found for test '#{test.name}' (`#{test.file}`)!)) unless relevant_notes.values.count(smaller_diff_ratio) == 1
141
+
142
+ # Re-instantiate a `Gitlab::ObjectifiedHash` object after having converted it to a hash in #find_relevant_failure_issues above.
143
+ best_matching_note = Gitlab::ObjectifiedHash.new(best_matching_note)
144
+
145
+ test.failure_issue ||= "#{issue.web_url}#note_#{best_matching_note.id}"
146
+
147
+ best_matching_note
148
+ end
149
+
150
+ def find_relevant_failure_discussion_note(issue:, test:, reports_discussion:)
151
+ return [] unless reports_discussion.notes.size > 1
152
+
153
+ clean_test_stacktrace = cleaned_stack_trace_from_test(test: test)
154
+
155
+ # We're skipping the first note of the discussion as this is the "non-collapsible note", aka
156
+ # the "header note", which doesn't contain any stack trace.
157
+ reports_discussion.notes[1..].each_with_object({}) do |note, memo|
158
+ clean_note_stacktrace = cleaned_stack_trace_from_note(issue: issue, note: note)
159
+ diff_ratio = diff_ratio_between_test_and_note_stacktraces(
160
+ issue: issue,
161
+ note: note,
162
+ test_stacktrace: clean_test_stacktrace,
163
+ note_stacktrace: clean_note_stacktrace)
164
+
165
+ memo[note.to_h] = diff_ratio if diff_ratio
166
+ end
167
+ end
168
+
169
+ def cleaned_stack_trace_from_test(test:)
170
+ sanitize_stacktrace(stacktrace: test.full_stacktrace, regex: FAILURE_STACKTRACE_REGEX) || test.full_stacktrace
171
+ end
172
+
173
+ def cleaned_stack_trace_from_note(issue:, note:)
174
+ note_stacktrace = sanitize_stacktrace(stacktrace: note.body, regex: ISSUE_STACKTRACE_REGEX)
175
+ return note_stacktrace if note_stacktrace
176
+
177
+ puts " => [DEBUG] Stacktrace couldn't be found for #{issue.web_url}#note_#{note.id}!"
178
+ end
179
+
180
+ def sanitize_stacktrace(stacktrace:, regex:)
181
+ stacktrace_match = stacktrace.match(regex)
182
+
183
+ if stacktrace_match
184
+ stacktrace_match[:stacktrace].gsub(/^\s*#.*$/, '').gsub(/^[[:space:]]+/, '').strip
185
+ else
186
+ puts " => [DEBUG] Stacktrace doesn't match the regex (#{regex})!"
187
+ end
188
+ end
189
+
190
+ def diff_ratio_between_test_and_note_stacktraces(issue:, note:, test_stacktrace:, note_stacktrace:)
191
+ return if note_stacktrace.nil?
192
+
193
+ stack_trace_comparator = StackTraceComparator.new(test_stacktrace, note_stacktrace)
194
+
195
+ if stack_trace_comparator.lower_or_equal_to_diff_ratio?(max_diff_ratio)
196
+ puts " => [DEBUG] Note #{issue.web_url}#note_#{note.id} has an acceptable diff ratio of #{stack_trace_comparator.diff_percent}%."
197
+ # The `Gitlab::ObjectifiedHash` class overrides `#hash` which is used by `Hash#[]=` to compute the hash key.
198
+ # 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.
199
+ # See:
200
+ # - https://gitlab.com/gitlab-org/gitlab-qa/-/merge_requests/587#note_453336995
201
+ # - https://github.com/NARKOZ/gitlab/commit/cbdbd1e32623f018a8fae39932a8e3bc4d929abb?_pjax=%23js-repo-pjax-container#r44484494
202
+ stack_trace_comparator.diff_ratio
203
+ else
204
+ puts " => [DEBUG] Found note #{issue.web_url}#note_#{note.id} but stacktraces are too different (#{stack_trace_comparator.diff_percent}%).\n"
205
+ puts " => [DEBUG] Issue stacktrace:\n----------------\n#{note_stacktrace}\n----------------\n"
206
+ puts " => [DEBUG] Failure stacktrace:\n----------------\n#{test_stacktrace}\n----------------\n"
207
+ end
208
+ end
209
+
210
+ def add_report_for_test(current_reports_content:, test:)
211
+ increment_reports(
212
+ current_reports_content: current_reports_content,
213
+ test: test,
214
+ reports_section_header: REPORT_SECTION_HEADER,
215
+ item_extra_content: found_label,
216
+ reports_extra_content: "##### Stack trace\n\n```\n#{test.full_stacktrace}\n```"
217
+ )
218
+ end
219
+
220
+ def found_label
221
+ if ENV.key?('CI_MERGE_REQUEST_IID')
222
+ FOUND_IN_MR_LABEL
223
+ else
224
+ FOUND_IN_MASTER_LABEL
225
+ end
226
+ end
227
+
228
+ def identity_labels_quick_action
229
+ labels_list = IDENTITY_LABELS.map { |label| %(~"#{label}") }.join(' ')
230
+ %(/label #{labels_list})
231
+ end
232
+
233
+ def relate_issues_quick_actions(issues)
234
+ issues.map do |issue|
235
+ "/relate #{issue.web_url}"
236
+ end.join("\n")
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
@@ -4,14 +4,13 @@ module GitlabQuality
4
4
  module TestTooling
5
5
  module Report
6
6
  # Uses the API to create GitLab issues for any passed test coming from JSON test reports.
7
- # We expect the test reports to come from rspec-retry, or from a new RSpec process where
8
- # we retried failing specs.
7
+ # We expect the test reports to come from a new RSpec process where we retried failing specs.
9
8
  #
10
- # - Takes the JSON test reports like rspec-*.json (typically from rspec-retry gem)`
9
+ # - Takes the JSON test reports like rspec-*.json
11
10
  # - Takes a project where flaky test issues should be created
12
11
  # - For every passed test in the report:
13
- # - Find issue by test hash
14
- # - Reopen issue if it already exists, but is closed
12
+ # - Find issue by test hash or create a new issue if no issue was found
13
+ # - Add a flakiness report in the "Flakiness reports" note
15
14
  class FlakyTestIssue < ReportAsIssue
16
15
  include Concerns::GroupAndCategoryLabels
17
16
  include Concerns::IssueReports
@@ -84,17 +83,20 @@ module GitlabQuality
84
83
  end
85
84
 
86
85
  def add_report_to_issue(issue:, test:, related_issues:)
87
- reports_note = existing_reports_note(issue: issue)
86
+ current_reports_note = existing_reports_note(issue: issue)
87
+ new_reports_list = add_report_for_test(current_reports_content: current_reports_note&.body.to_s, test: test)
88
+
88
89
  note_body = [
89
- report_body(reports_note: reports_note, test: test),
90
+ new_reports_list.to_s,
91
+ flakiness_status_labels_quick_action(new_reports_list.reports_count),
90
92
  identity_labels_quick_action,
91
93
  relate_issues_quick_actions(related_issues)
92
94
  ].join("\n")
93
95
 
94
- if reports_note
96
+ if current_reports_note
95
97
  gitlab.edit_issue_note(
96
98
  issue_iid: issue.iid,
97
- note_id: reports_note.id,
99
+ note_id: current_reports_note.id,
98
100
  note: note_body
99
101
  )
100
102
  else
@@ -108,9 +110,9 @@ module GitlabQuality
108
110
  end
109
111
  end
110
112
 
111
- def report_body(reports_note:, test:)
113
+ def add_report_for_test(current_reports_content:, test:)
112
114
  increment_reports(
113
- current_reports_content: reports_note&.body.to_s,
115
+ current_reports_content: current_reports_content,
114
116
  test: test,
115
117
  reports_section_header: REPORT_SECTION_HEADER,
116
118
  item_extra_content: found_label,
@@ -126,6 +128,19 @@ module GitlabQuality
126
128
  end
127
129
  end
128
130
 
131
+ def flakiness_status_labels_quick_action(reports_count)
132
+ case reports_count
133
+ when 1000..Float::INFINITY
134
+ '/label ~"flakiness::1"'
135
+ when 500..999
136
+ '/label ~"flakiness::2"'
137
+ when 10..499
138
+ '/label ~"flakiness::3"'
139
+ else
140
+ '/label ~"flakiness::4"'
141
+ end
142
+ end
143
+
129
144
  def identity_labels_quick_action
130
145
  labels_list = IDENTITY_LABELS.map { |label| %(~"#{label}") }.join(' ')
131
146
  %(/label #{labels_list})
@@ -2,7 +2,6 @@
2
2
 
3
3
  require 'nokogiri'
4
4
  require 'rubygems/text'
5
- require 'amatch'
6
5
 
7
6
  module GitlabQuality
8
7
  module TestTooling
@@ -17,7 +16,6 @@ module GitlabQuality
17
16
  include TestTooling::Concerns::FindSetDri
18
17
  include Concerns::GroupAndCategoryLabels
19
18
  include Concerns::IssueReports
20
- include Amatch
21
19
 
22
20
  DEFAULT_MAX_DIFF_RATIO_FOR_DETECTION = 0.15
23
21
  SYSTEMIC_EXCEPTIONS_THRESHOLD = 10
@@ -26,10 +24,6 @@ module GitlabQuality
26
24
  ISSUE_STACKTRACE_REGEX = /### Stack trace\s*(```)#{FAILURE_STACKTRACE_REGEX}(```)\n*\n###/m
27
25
 
28
26
  NEW_ISSUE_LABELS = Set.new(%w[test failure::new priority::2]).freeze
29
- IGNORED_FAILURES = [
30
- 'Net::ReadTimeout',
31
- '403 Forbidden - Your account has been blocked'
32
- ].freeze
33
27
  SCREENSHOT_IGNORED_ERRORS = ['500 Internal Server Error', 'fabricate_via_api!', 'Error Code 500'].freeze
34
28
 
35
29
  MultipleIssuesFound = Class.new(StandardError)
@@ -168,7 +162,9 @@ module GitlabQuality
168
162
 
169
163
  next if pipeline != pipeline_env_from_job_url(job_url_from_issue)
170
164
 
171
- compare_stack_traces(cleaned_stack_trace_from_test(test), cleaned_stack_trace_from_issue(issue)) < max_diff_ratio
165
+ stack_trace_comparator = StackTraceComparator.new(cleaned_stack_trace_from_test(test), cleaned_stack_trace_from_issue(issue))
166
+
167
+ stack_trace_comparator.lower_than_diff_ratio?(max_diff_ratio)
172
168
  end
173
169
  end
174
170
 
@@ -196,17 +192,6 @@ module GitlabQuality
196
192
  )
197
193
  end
198
194
 
199
- def full_stacktrace(test)
200
- test.failures.each do |failure|
201
- message = failure['message'] || ""
202
- message_lines = failure['message_lines'] || []
203
-
204
- next if IGNORED_FAILURES.any? { |e| message.include?(e) }
205
-
206
- return message_lines.empty? ? message : message_lines.join("\n")
207
- end
208
- end
209
-
210
195
  def cleaned_stack_trace_from_issue(issue)
211
196
  relevant_issue_stacktrace = find_issue_stacktrace(issue)
212
197
  return unless relevant_issue_stacktrace
@@ -215,20 +200,11 @@ module GitlabQuality
215
200
  end
216
201
 
217
202
  def cleaned_stack_trace_from_test(test)
218
- test_failure_stacktrace = sanitize_stacktrace(full_stacktrace(test),
219
- FAILURE_STACKTRACE_REGEX) || full_stacktrace(test)
203
+ test_failure_stacktrace = sanitize_stacktrace(test.full_stacktrace,
204
+ FAILURE_STACKTRACE_REGEX) || test.full_stacktrace
220
205
  remove_unique_resource_names(test_failure_stacktrace)
221
206
  end
222
207
 
223
- def compare_stack_traces(stack_trace_first, stack_trace_second)
224
- calculate_diff_ratio(stack_trace_first, stack_trace_second)
225
- end
226
-
227
- def calculate_diff_ratio(stack_trace_first, stack_trace_second)
228
- distance = Levenshtein.new(stack_trace_first).match(stack_trace_second)
229
- distance.zero? ? 0.0 : (distance.to_f / stack_trace_first.size).round(3)
230
- end
231
-
232
208
  def find_relevant_failure_issues(test) # rubocop:disable Metrics/AbcSize
233
209
  clean_first_test_failure_stacktrace = cleaned_stack_trace_from_test(test)
234
210
  # Search with the `search` param returns 500 errors, so we filter by `base_issue_labels` and then filter further in Ruby
@@ -236,17 +212,18 @@ module GitlabQuality
236
212
  clean_relevant_issue_stacktrace = cleaned_stack_trace_from_issue(issue)
237
213
  next if clean_relevant_issue_stacktrace.nil?
238
214
 
239
- diff_ratio = compare_stack_traces(clean_first_test_failure_stacktrace, clean_relevant_issue_stacktrace)
240
- if diff_ratio <= max_diff_ratio
241
- puts " => [DEBUG] Issue #{issue.web_url} has an acceptable diff ratio of #{(diff_ratio * 100).round(2)}%."
215
+ stack_trace_comparator = StackTraceComparator.new(clean_first_test_failure_stacktrace, clean_relevant_issue_stacktrace)
216
+
217
+ if stack_trace_comparator.lower_or_equal_to_diff_ratio?(max_diff_ratio)
218
+ puts " => [DEBUG] Issue #{issue.web_url} has an acceptable diff ratio of #{stack_trace_comparator.diff_percent}%."
242
219
  # The `Gitlab::ObjectifiedHash` class overrides `#hash` which is used by `Hash#[]=` to compute the hash key.
243
220
  # 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.
244
221
  # See:
245
222
  # - https://gitlab.com/gitlab-org/gitlab-qa/-/merge_requests/587#note_453336995
246
223
  # - https://github.com/NARKOZ/gitlab/commit/cbdbd1e32623f018a8fae39932a8e3bc4d929abb?_pjax=%23js-repo-pjax-container#r44484494
247
- memo[issue.to_h] = diff_ratio
224
+ memo[issue.to_h] = stack_trace_comparator.diff_ratio
248
225
  else
249
- puts " => [DEBUG] Found issue #{issue.web_url} but stacktraces are too different (#{(diff_ratio * 100).round(2)}%).\n"
226
+ puts " => [DEBUG] Found issue #{issue.web_url} but stacktraces are too different (#{stack_trace_comparator.diff_percent}%).\n"
250
227
  puts " => [DEBUG] Issue stacktrace:\n----------------\n#{clean_relevant_issue_stacktrace}\n----------------\n"
251
228
  puts " => [DEBUG] Failure stacktrace:\n----------------\n#{clean_first_test_failure_stacktrace}\n----------------\n"
252
229
  end
@@ -295,7 +272,7 @@ module GitlabQuality
295
272
  def new_issue_description(test)
296
273
  super + [
297
274
  "\n### Stack trace",
298
- "```\n#{full_stacktrace(test)}\n```",
275
+ "```\n#{test.full_stacktrace}\n```",
299
276
  screenshot_section(test),
300
277
  system_log_errors_section(test),
301
278
  initial_reports_section(test)
@@ -345,7 +322,7 @@ module GitlabQuality
345
322
  state_event = issue.state == 'closed' ? 'reopen' : nil
346
323
 
347
324
  issue_attrs = {
348
- description: increment_reports(current_reports_content: issue.description, test: test),
325
+ description: increment_reports(current_reports_content: issue.description, test: test).to_s,
349
326
  labels: up_to_date_labels(test: test, issue: issue)
350
327
  }
351
328
  issue_attrs[:state_event] = state_event if state_event
@@ -357,7 +334,7 @@ module GitlabQuality
357
334
  def screenshot_section(test)
358
335
  return unless test.screenshot?
359
336
 
360
- failure = full_stacktrace(test)
337
+ failure = test.full_stacktrace
361
338
  return if SCREENSHOT_IGNORED_ERRORS.any? { |e| failure.include?(e) }
362
339
 
363
340
  relative_url = gitlab.upload_file(file_fullpath: test.screenshot_image)
@@ -374,7 +351,7 @@ module GitlabQuality
374
351
  return false unless test.failures?
375
352
 
376
353
  puts " => Systemic failures detected: #{systemic_failure_messages}" if systemic_failure_messages.any?
377
- failure_to_ignore = IGNORED_FAILURES + systemic_failure_messages
354
+ failure_to_ignore = TestResult::BaseTestResult::IGNORED_FAILURES + systemic_failure_messages
378
355
 
379
356
  reason = ignored_failure_reason(test.failures, failure_to_ignore)
380
357
 
@@ -105,13 +105,10 @@ module GitlabQuality
105
105
  due_date: new_issue_due_date(test),
106
106
  confidential: confidential
107
107
  }.compact
108
- issue = gitlab.create_issue(**attrs)
109
108
 
110
- new_link = issue_type == 'test_case' ? issue&.web_url&.sub('/issues/', '/quality/test_cases/') : issue&.web_url
111
-
112
- puts "Created new #{issue_type}: #{new_link}"
113
-
114
- issue
109
+ gitlab.create_issue(**attrs).tap do |issue|
110
+ puts "Created new #{issue_type}: #{issue&.web_url}"
111
+ end
115
112
  end
116
113
 
117
114
  def issue_labels(issue)
@@ -46,7 +46,7 @@ module GitlabQuality
46
46
  gitlab.add_note_to_issue_discussion_as_thread(
47
47
  iid: issue.iid,
48
48
  discussion_id: discussion.id,
49
- body: failure_summary)
49
+ note: failure_summary)
50
50
  return true
51
51
  end
52
52
 
@@ -64,17 +64,19 @@ module GitlabQuality
64
64
  end
65
65
 
66
66
  def add_report_to_issue(issue:, test:, related_issues:)
67
- reports_note = existing_reports_note(issue: issue)
67
+ current_reports_note = existing_reports_note(issue: issue)
68
+ new_reports_list = add_report_for_test(current_reports_content: current_reports_note&.body.to_s, test: test)
69
+
68
70
  note_body = [
69
- report_body(reports_note: reports_note, test: test),
71
+ new_reports_list.to_s,
70
72
  identity_labels_quick_action,
71
73
  relate_issues_quick_actions(related_issues)
72
74
  ].join("\n")
73
75
 
74
- if reports_note
76
+ if current_reports_note
75
77
  gitlab.edit_issue_note(
76
78
  issue_iid: issue.iid,
77
- note_id: reports_note.id,
79
+ note_id: current_reports_note.id,
78
80
  note: note_body
79
81
  )
80
82
  else
@@ -88,9 +90,9 @@ module GitlabQuality
88
90
  end
89
91
  end
90
92
 
91
- def report_body(reports_note:, test:)
93
+ def add_report_for_test(current_reports_content:, test:)
92
94
  increment_reports(
93
- current_reports_content: reports_note&.body.to_s,
95
+ current_reports_content: current_reports_content,
94
96
  test: test,
95
97
  reports_section_header: REPORT_SECTION_HEADER,
96
98
  item_extra_content: "(#{test.run_time} seconds) #{found_label}",
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'amatch'
4
+
5
+ module GitlabQuality
6
+ module TestTooling
7
+ class StackTraceComparator
8
+ include Amatch
9
+
10
+ def initialize(first_trace, second_trace)
11
+ @first_trace = first_trace
12
+ @second_trace = second_trace
13
+ end
14
+
15
+ def diff_ratio
16
+ @diff_ratio ||= (1 - first_trace.levenshtein_similar(second_trace))
17
+ end
18
+
19
+ def diff_percent
20
+ (diff_ratio * 100).round(2)
21
+ end
22
+
23
+ def lower_than_diff_ratio?(max_diff_ratio)
24
+ diff_ratio < max_diff_ratio
25
+ end
26
+
27
+ def lower_or_equal_to_diff_ratio?(max_diff_ratio)
28
+ diff_ratio <= max_diff_ratio
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :first_trace, :second_trace
34
+ end
35
+ end
36
+ end
@@ -4,6 +4,11 @@ module GitlabQuality
4
4
  module TestTooling
5
5
  module TestResult
6
6
  class BaseTestResult
7
+ IGNORED_FAILURES = [
8
+ 'Net::ReadTimeout',
9
+ '403 Forbidden - Your account has been blocked'
10
+ ].freeze
11
+
7
12
  attr_reader :report
8
13
 
9
14
  def initialize(report)
@@ -41,6 +46,17 @@ module GitlabQuality
41
46
  def failures?
42
47
  failures.any?
43
48
  end
49
+
50
+ def full_stacktrace
51
+ failures.each do |failure|
52
+ message = failure['message'] || ""
53
+ message_lines = failure['message_lines'] || []
54
+
55
+ next if IGNORED_FAILURES.any? { |e| message.include?(e) }
56
+
57
+ return message_lines.empty? ? message : message_lines.join("\n")
58
+ end
59
+ end
44
60
  end
45
61
  end
46
62
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GitlabQuality
4
4
  module TestTooling
5
- VERSION = "1.21.1"
5
+ VERSION = "1.23.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab_quality-test_tooling
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.21.1
4
+ version: 1.23.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - GitLab Quality
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-04-03 00:00:00.000000000 Z
11
+ date: 2024-04-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: climate_control
@@ -308,6 +308,20 @@ dependencies:
308
308
  - - "<"
309
309
  - !ruby/object:Gem::Version
310
310
  version: '4'
311
+ - !ruby/object:Gem::Dependency
312
+ name: rspec-parameterized
313
+ requirement: !ruby/object:Gem::Requirement
314
+ requirements:
315
+ - - "~>"
316
+ - !ruby/object:Gem::Version
317
+ version: 1.0.0
318
+ type: :runtime
319
+ prerelease: false
320
+ version_requirements: !ruby/object:Gem::Requirement
321
+ requirements:
322
+ - - "~>"
323
+ - !ruby/object:Gem::Version
324
+ version: 1.0.0
311
325
  - !ruby/object:Gem::Dependency
312
326
  name: table_print
313
327
  requirement: !ruby/object:Gem::Requirement
@@ -342,24 +356,11 @@ dependencies:
342
356
  - - "<"
343
357
  - !ruby/object:Gem::Version
344
358
  version: '3'
345
- - !ruby/object:Gem::Dependency
346
- name: rspec-parameterized
347
- requirement: !ruby/object:Gem::Requirement
348
- requirements:
349
- - - "~>"
350
- - !ruby/object:Gem::Version
351
- version: 1.0.0
352
- type: :runtime
353
- prerelease: false
354
- version_requirements: !ruby/object:Gem::Requirement
355
- requirements:
356
- - - "~>"
357
- - !ruby/object:Gem::Version
358
- version: 1.0.0
359
359
  description: A collection of test-related tools.
360
360
  email:
361
361
  - quality@gitlab.com
362
362
  executables:
363
+ - failed-test-issues
363
364
  - flaky-test-issues
364
365
  - generate-test-session
365
366
  - knapsack-report-issues
@@ -388,6 +389,7 @@ files:
388
389
  - LICENSE.txt
389
390
  - README.md
390
391
  - Rakefile
392
+ - exe/failed-test-issues
391
393
  - exe/flaky-test-issues
392
394
  - exe/generate-test-session
393
395
  - exe/knapsack-report-issues
@@ -421,6 +423,7 @@ files:
421
423
  - lib/gitlab_quality/test_tooling/report/concerns/issue_reports.rb
422
424
  - lib/gitlab_quality/test_tooling/report/concerns/results_reporter.rb
423
425
  - lib/gitlab_quality/test_tooling/report/concerns/utils.rb
426
+ - lib/gitlab_quality/test_tooling/report/failed_test_issue.rb
424
427
  - lib/gitlab_quality/test_tooling/report/flaky_test_issue.rb
425
428
  - lib/gitlab_quality/test_tooling/report/generate_test_session.rb
426
429
  - lib/gitlab_quality/test_tooling/report/issue_logger.rb
@@ -438,6 +441,7 @@ files:
438
441
  - lib/gitlab_quality/test_tooling/runtime/logger.rb
439
442
  - lib/gitlab_quality/test_tooling/slack/post_to_slack.rb
440
443
  - lib/gitlab_quality/test_tooling/slack/post_to_slack_dry.rb
444
+ - lib/gitlab_quality/test_tooling/stack_trace_comparator.rb
441
445
  - lib/gitlab_quality/test_tooling/summary_table.rb
442
446
  - lib/gitlab_quality/test_tooling/support/http_request.rb
443
447
  - lib/gitlab_quality/test_tooling/system_logs/finders/json_log_finder.rb