gitlab_quality-test_tooling 1.21.1 → 1.23.0

Sign up to get free protection for your applications and to get access to all the features.
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