gitlab-qa 6.7.0 → 6.11.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.
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module QA
5
+ module Report
6
+ class GitlabIssueDryClient < GitlabIssueClient
7
+ def create_issue(title:, description:, labels:)
8
+ attrs = { description: description, labels: labels }
9
+
10
+ puts "The following issue would have been created:"
11
+ puts "project: #{project}, title: #{title}, attrs: #{attrs}"
12
+ end
13
+
14
+ def edit_issue(iid:, options: {})
15
+ puts "The #{project}##{iid} issue would have been updated with: #{options}"
16
+ end
17
+
18
+ def create_issue_note(iid:, note:)
19
+ puts "The following note would have been posted on #{project}##{iid} issue: #{note}"
20
+ end
21
+
22
+ def add_note_to_issue_discussion_as_thread(iid:, discussion_id:, body:)
23
+ puts "The following discussion note would have been posted on #{project}##{iid} (discussion #{discussion_id}) issue: #{body}"
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -6,7 +6,7 @@ module Gitlab
6
6
  module QA
7
7
  module Report
8
8
  class JsonTestResults < BaseTestResults
9
- def write(path)
9
+ def write
10
10
  json = results.merge('examples' => testcases.map(&:report))
11
11
 
12
12
  File.write(path, JSON.pretty_generate(json))
@@ -14,7 +14,7 @@ module Gitlab
14
14
 
15
15
  private
16
16
 
17
- def parse(path)
17
+ def parse
18
18
  JSON.parse(File.read(path))
19
19
  end
20
20
 
@@ -6,13 +6,13 @@ module Gitlab
6
6
  module QA
7
7
  module Report
8
8
  class JUnitTestResults < BaseTestResults
9
- def write(path)
9
+ def write
10
10
  # Ignore it for now
11
11
  end
12
12
 
13
13
  private
14
14
 
15
- def parse(path)
15
+ def parse
16
16
  Nokogiri::XML.parse(File.read(path))
17
17
  end
18
18
 
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+ require 'active_support/core_ext/enumerable'
5
+ require 'rubygems/text'
6
+
7
+ module Gitlab
8
+ module QA
9
+ module Report
10
+ # Uses the API to create or update GitLab issues with the results of tests from RSpec report files.
11
+ class RelateFailureIssue < ReportAsIssue
12
+ DEFAULT_MAX_DIFF_RATIO_FOR_DETECTION = 0.05
13
+ STACKTRACE_REGEX = %r{### Stack trace\s*(```)\s*.*(Failure/Error:.+)(\1)}m.freeze
14
+ NEW_ISSUE_LABELS = Set.new(%w[QA Quality test failure::investigating priority::2]).freeze
15
+
16
+ MultipleIssuesFound = Class.new(StandardError)
17
+
18
+ def initialize(max_diff_ratio: DEFAULT_MAX_DIFF_RATIO_FOR_DETECTION, **kwargs)
19
+ super
20
+ @max_diff_ratio = max_diff_ratio.to_f
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :max_diff_ratio
26
+
27
+ def run!
28
+ puts "Reporting test failures in `#{files.join(',')}` as issues in project `#{project}` via the API at `#{Runtime::Env.gitlab_api_base}`."
29
+
30
+ test_results_per_file do |test_results|
31
+ puts "=> Reporting tests in #{test_results.path}"
32
+
33
+ test_results.each do |test|
34
+ next if test.failures.empty?
35
+
36
+ relate_test_to_issue(test)
37
+ end
38
+
39
+ test_results.write
40
+ end
41
+ end
42
+
43
+ def relate_test_to_issue(test)
44
+ puts " => Searching issues for test '#{test.name}'..."
45
+
46
+ begin
47
+ issue = find_or_create_issue(test)
48
+ return unless issue
49
+
50
+ update_labels(issue, test)
51
+ post_failed_job_note(issue, test)
52
+ puts " => Marked #{issue.web_url} as related to #{test.testcase}."
53
+ rescue MultipleIssuesFound => e
54
+ warn(e.message)
55
+ end
56
+ end
57
+
58
+ def find_or_create_issue(test)
59
+ issue, diff_ratio = find_failure_issue(test)
60
+
61
+ if issue
62
+ puts " => Found issue #{issue.web_url} for test '#{test.name}' with a diff ratio of #{(diff_ratio * 100).round(2)}%."
63
+ else
64
+ issue = create_issue(test)
65
+ puts " => Created new issue: #{issue.web_url} for test '#{test.name}'." if issue
66
+ end
67
+
68
+ issue
69
+ end
70
+
71
+ def failure_issues(test)
72
+ gitlab.find_issues(options: { state: 'opened', labels: 'QA' }).select do |issue|
73
+ issue_title = issue.title.strip
74
+ issue_title.include?(test.name) || issue_title.include?(partial_file_path(test.file))
75
+ end
76
+ end
77
+
78
+ def find_relevant_failure_issues(test) # rubocop:disable Metrics/AbcSize
79
+ ld = Class.new.extend(Gem::Text).method(:levenshtein_distance)
80
+ first_test_failure_stacktrace = test.failures.first['message_lines'].join("\n")
81
+
82
+ # Search with the `search` param returns 500 errors, so we filter by ~QA and then filter further in Ruby
83
+ failure_issues(test).each_with_object({}) do |issue, memo|
84
+ relevant_issue_stacktrace = find_issue_stacktrace(issue)
85
+ next unless relevant_issue_stacktrace
86
+
87
+ distance = ld.call(first_test_failure_stacktrace, relevant_issue_stacktrace)
88
+ diff_ratio = (distance.to_f / first_test_failure_stacktrace.size).round(3)
89
+ if diff_ratio <= max_diff_ratio
90
+ puts " => [DEBUG] Issue #{issue} has an acceptable diff ratio of #{(diff_ratio * 100).round(2)}%."
91
+ memo[issue] = diff_ratio
92
+ else
93
+ puts " => [DEBUG] Found issue #{issue.web_url} but stacktraces are too different (#{(diff_ratio * 100).round(2)}%)."
94
+ end
95
+ end
96
+ end
97
+
98
+ def find_issue_stacktrace(issue)
99
+ issue_stacktrace_match = issue.description.match(STACKTRACE_REGEX)
100
+
101
+ if issue_stacktrace_match
102
+ issue_stacktrace_match[2].gsub(/^#.*$/, '').strip
103
+ else
104
+ puts " => [DEBUG] Stacktrace couldn't be found for #{issue.web_url}:\n\n#{issue.description}\n\n----------------------------------\n"
105
+ end
106
+ end
107
+
108
+ def find_failure_issue(test)
109
+ relevant_issues = find_relevant_failure_issues(test)
110
+
111
+ return nil if relevant_issues.empty?
112
+
113
+ best_matching_issue, smaller_diff_ratio = relevant_issues.min_by { |_, diff_ratio| diff_ratio }
114
+
115
+ unless relevant_issues.values.count(smaller_diff_ratio) == 1 # rubocop:disable Style/IfUnlessModifier
116
+ raise(MultipleIssuesFound, %(Too many issues found for test '#{test.name}' (`#{test.file}`)!))
117
+ end
118
+
119
+ test.failure_issue ||= best_matching_issue.web_url
120
+
121
+ [best_matching_issue, smaller_diff_ratio]
122
+ end
123
+
124
+ def new_issue_description(test)
125
+ super + [
126
+ "\n\n### Stack trace",
127
+ "```\n#{test.failures.first['message_lines'].join("\n")}\n```",
128
+ "First happened in #{test.ci_job_url}."
129
+ ].join("\n\n")
130
+ end
131
+
132
+ def deploy_environment_label
133
+ environment = Runtime::Env.deploy_environment
134
+
135
+ case environment
136
+ when 'production'
137
+ 'found:gitlab.com'
138
+ when 'canary', 'staging'
139
+ "found:#{environment}.gitlab.com"
140
+ when 'preprod'
141
+ 'found:pre.gitlab.com'
142
+ when 'staging-orchestrated', 'nightly', 'master'
143
+ "found:#{environment}"
144
+ else
145
+ raise "No `found:*` label for the `#{environment}` environment!"
146
+ end
147
+ end
148
+
149
+ def new_issue_labels(test)
150
+ NEW_ISSUE_LABELS + up_to_date_labels(test: test)
151
+ end
152
+
153
+ def up_to_date_labels(test:, issue: nil)
154
+ super << deploy_environment_label
155
+ end
156
+
157
+ def post_failed_job_note(issue, test)
158
+ gitlab.create_issue_note(iid: issue.iid, note: "/relate #{test.testcase}")
159
+ end
160
+
161
+ def new_issue_title(test)
162
+ "Failure in #{super}"
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -1,13 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'set'
4
+
3
5
  module Gitlab
4
6
  module QA
5
7
  module Report
6
8
  class ReportAsIssue
7
- def initialize(token:, input_files:, project: nil)
8
- @gitlab = GitlabIssueClient.new(token: token, project: project)
9
- @files = Array(input_files)
9
+ MAX_TITLE_LENGTH = 255
10
+
11
+ def initialize(token:, input_files:, project: nil, dry_run: false, **kwargs)
10
12
  @project = project
13
+ @gitlab = (dry_run ? GitlabIssueDryClient : GitlabIssueClient).new(token: token, project: project)
14
+ @files = Array(input_files)
11
15
  end
12
16
 
13
17
  def invoke!
@@ -24,6 +28,18 @@ module Gitlab
24
28
  raise NotImplementedError
25
29
  end
26
30
 
31
+ def new_issue_title(test)
32
+ "#{partial_file_path(test.file)} | #{search_safe(test.name)}".strip
33
+ end
34
+
35
+ def new_issue_description(test)
36
+ "### Full description\n\n#{search_safe(test.name)}\n\n### File path\n\n#{test.file}"
37
+ end
38
+
39
+ def new_issue_labels(test)
40
+ []
41
+ end
42
+
27
43
  def validate_input!
28
44
  assert_project!
29
45
  assert_input_files!(files)
@@ -41,6 +57,92 @@ module Gitlab
41
57
 
42
58
  abort "Please provide valid JUnit report files. No files were found matching `#{files.join(',')}`"
43
59
  end
60
+
61
+ def test_results_per_file
62
+ Dir.glob(files).each do |path|
63
+ extension = File.extname(path)
64
+
65
+ test_results =
66
+ case extension
67
+ when '.json'
68
+ Report::JsonTestResults.new(path)
69
+ when '.xml'
70
+ Report::JUnitTestResults.new(path)
71
+ else
72
+ raise "Unknown extension #{extension}"
73
+ end
74
+
75
+ yield test_results
76
+ end
77
+ end
78
+
79
+ def create_issue(test)
80
+ gitlab.create_issue(
81
+ title: title_from_test(test),
82
+ description: new_issue_description(test),
83
+ labels: new_issue_labels(test).to_a
84
+ )
85
+ end
86
+
87
+ def issue_labels(issue)
88
+ issue&.labels&.to_set || Set.new
89
+ end
90
+
91
+ def update_labels(issue, test)
92
+ new_labels = up_to_date_labels(test: test, issue: issue)
93
+
94
+ return if issue_labels(issue) == new_labels
95
+
96
+ gitlab.edit_issue(iid: issue.iid, options: { labels: new_labels.to_a })
97
+ end
98
+
99
+ def up_to_date_labels(test:, issue: nil)
100
+ labels = issue_labels(issue)
101
+ labels << "Enterprise Edition" if ee_test?(test)
102
+ quarantine_job? ? labels << "quarantine" : labels.delete("quarantine")
103
+
104
+ labels
105
+ end
106
+
107
+ def ee_test?(test)
108
+ test.file =~ %r{features/ee/(api|browser_ui)}
109
+ end
110
+
111
+ def quarantine_job?
112
+ Runtime::Env.ci_job_name&.include?('quarantine')
113
+ end
114
+
115
+ def partial_file_path(path)
116
+ path.match(/((api|browser_ui).*)/i)[1]
117
+ end
118
+
119
+ def title_from_test(test)
120
+ title = new_issue_title(test)
121
+
122
+ return title unless title.length > MAX_TITLE_LENGTH
123
+
124
+ "#{title[0...MAX_TITLE_LENGTH - 3]}..."
125
+ end
126
+
127
+ def search_safe(value)
128
+ value.delete('"')
129
+ end
130
+
131
+ def pipeline
132
+ # Gets the name of the pipeline the test was run in, to be used as the key of a scoped label
133
+ #
134
+ # Tests can be run in several pipelines:
135
+ # gitlab-qa, nightly, master, staging, canary, production, preprod, and MRs
136
+ #
137
+ # Some of those run in their own project, so CI_PROJECT_NAME is the name we need. Those are:
138
+ # nightly, staging, canary, production, and preprod
139
+ #
140
+ # MR, master, and gitlab-qa tests run in gitlab-qa, but we only want to report tests run on master
141
+ # because the other pipelines will be monitored by the author of the MR that triggered them.
142
+ # So we assume that we're reporting a master pipeline if the project name is 'gitlab-qa'.
143
+
144
+ @pipeline ||= Runtime::Env.pipeline_from_project_name
145
+ end
44
146
  end
45
147
  end
46
148
  end
@@ -8,37 +8,23 @@ module Gitlab
8
8
  module Report
9
9
  # Uses the API to create or update GitLab issues with the results of tests from RSpec report files.
10
10
  class ResultsInIssues < ReportAsIssue
11
- MAX_TITLE_LENGTH = 255
12
-
13
11
  private
14
12
 
15
13
  def run!
16
14
  puts "Reporting test results in `#{files.join(',')}` as issues in project `#{project}` via the API at `#{Runtime::Env.gitlab_api_base}`."
17
15
 
18
- Dir.glob(files).each do |path|
19
- puts "Reporting tests in #{path}"
20
- extension = File.extname(path)
21
-
22
- case extension
23
- when '.json'
24
- test_results = Report::JsonTestResults.new(path)
25
- when '.xml'
26
- test_results = Report::JUnitTestResults.new(path)
27
- else
28
- raise "Unknown extension #{extension}"
29
- end
16
+ test_results_per_file do |test_results|
17
+ puts "Reporting tests in #{test_results.path}"
30
18
 
31
19
  test_results.each do |test|
32
20
  report_test(test)
33
21
  end
34
22
 
35
- test_results.write(path)
23
+ test_results.write
36
24
  end
37
25
  end
38
26
 
39
27
  def report_test(test)
40
- return if test.skipped
41
-
42
28
  puts "Reporting test: #{test.file} | #{test.name}"
43
29
 
44
30
  issue = find_issue(test)
@@ -46,16 +32,23 @@ module Gitlab
46
32
  if issue
47
33
  puts "Found existing issue: #{issue.web_url}"
48
34
  else
35
+ # Don't create new issues for skipped tests
36
+ return if test.skipped
37
+
49
38
  issue = create_issue(test)
50
39
  puts "Created new issue: #{issue.web_url}"
51
40
  end
52
41
 
53
42
  test.testcase ||= issue.web_url
54
43
 
55
- update_labels(issue, test)
56
- note_status(issue, test)
44
+ labels_updated = update_labels(issue, test)
45
+ note_posted = note_status(issue, test)
57
46
 
58
- puts "Issue updated"
47
+ if labels_updated || note_posted
48
+ puts "Issue updated."
49
+ else
50
+ puts "Test passed, no update needed."
51
+ end
59
52
  end
60
53
 
61
54
  def find_issue(test)
@@ -72,12 +65,14 @@ module Gitlab
72
65
  issues.first
73
66
  end
74
67
 
75
- def create_issue(test)
76
- gitlab.create_issue(
77
- title: title_from_test(test),
78
- description: "### Full description\n\n#{search_safe(test.name)}\n\n### File path\n\n#{test.file}",
79
- labels: 'status::automated'
80
- )
68
+ def new_issue_labels(test)
69
+ %w[status::automated]
70
+ end
71
+
72
+ def up_to_date_labels(test:, issue: nil)
73
+ labels = super
74
+ labels.delete_if { |label| label.start_with?("#{pipeline}::") }
75
+ labels << (test.failures.empty? ? "#{pipeline}::passed" : "#{pipeline}::failed")
81
76
  end
82
77
 
83
78
  def iid_from_testcase_url(url)
@@ -85,37 +80,22 @@ module Gitlab
85
80
  end
86
81
 
87
82
  def search_term(test)
88
- %("#{test.file}" "#{search_safe(test.name)}")
89
- end
90
-
91
- def title_from_test(test)
92
- title = "#{partial_file_path(test.file)} | #{search_safe(test.name)}".strip
93
-
94
- return title unless title.length > MAX_TITLE_LENGTH
95
-
96
- "#{title[0...MAX_TITLE_LENGTH - 3]}..."
97
- end
98
-
99
- def partial_file_path(path)
100
- path.match(/((api|browser_ui).*)/i)[1]
101
- end
102
-
103
- def search_safe(value)
104
- value.delete('"')
83
+ %("#{partial_file_path(test.file)}" "#{search_safe(test.name)}")
105
84
  end
106
85
 
107
86
  def note_status(issue, test)
108
- return if test.failures.empty?
87
+ return false if test.skipped
88
+ return false if test.failures.empty?
109
89
 
110
90
  note = note_content(test)
111
91
 
112
- gitlab.handle_gitlab_client_exceptions do
113
- Gitlab.issue_discussions(project, issue.iid, order_by: 'created_at', sort: 'asc').each do |discussion|
114
- return add_note_to_discussion(issue.iid, discussion.id) if new_note_matches_discussion?(note, discussion)
115
- end
116
-
117
- Gitlab.create_issue_note(project, issue.iid, note)
92
+ gitlab.find_issue_discussions(iid: issue.iid).each do |discussion|
93
+ return gitlab.add_note_to_issue_discussion_as_thread(iid: issue.iid, discussion_id: discussion.id, body: failure_summary) if new_note_matches_discussion?(note, discussion)
118
94
  end
95
+
96
+ gitlab.create_issue_note(iid: issue.iid, note: note)
97
+
98
+ true
119
99
  end
120
100
 
121
101
  def note_content(test)
@@ -143,10 +123,6 @@ module Gitlab
143
123
  summary.join(' ')
144
124
  end
145
125
 
146
- def quarantine_job?
147
- Runtime::Env.ci_job_name&.include?('quarantine')
148
- end
149
-
150
126
  def new_note_matches_discussion?(note, discussion)
151
127
  note_error = error_and_stack_trace(note)
152
128
  discussion_error = error_and_stack_trace(discussion.notes.first['body'])
@@ -157,47 +133,7 @@ module Gitlab
157
133
  end
158
134
 
159
135
  def error_and_stack_trace(text)
160
- result = text.strip[/Error:(.*)/m, 1].to_s
161
-
162
- warn "Could not find `Error:` in text: #{text}" if result.empty?
163
-
164
- result
165
- end
166
-
167
- def add_note_to_discussion(issue_iid, discussion_id)
168
- gitlab.handle_gitlab_client_exceptions do
169
- Gitlab.add_note_to_issue_discussion_as_thread(project, issue_iid, discussion_id, body: failure_summary)
170
- end
171
- end
172
-
173
- def update_labels(issue, test)
174
- labels = issue.labels
175
- labels.delete_if { |label| label.start_with?("#{pipeline}::") }
176
- labels << (test.failures.empty? ? "#{pipeline}::passed" : "#{pipeline}::failed")
177
- labels << "Enterprise Edition" if ee_test?(test)
178
- quarantine_job? ? labels << "quarantine" : labels.delete("quarantine")
179
-
180
- gitlab.edit_issue(iid: issue.iid, options: { labels: labels })
181
- end
182
-
183
- def ee_test?(test)
184
- test.file =~ %r{features/ee/(api|browser_ui)}
185
- end
186
-
187
- def pipeline
188
- # Gets the name of the pipeline the test was run in, to be used as the key of a scoped label
189
- #
190
- # Tests can be run in several pipelines:
191
- # gitlab-qa, nightly, master, staging, canary, production, preprod, and MRs
192
- #
193
- # Some of those run in their own project, so CI_PROJECT_NAME is the name we need. Those are:
194
- # nightly, staging, canary, production, and preprod
195
- #
196
- # MR, master, and gitlab-qa tests run in gitlab-qa, but we only want to report tests run on master
197
- # because the other pipelines will be monitored by the author of the MR that triggered them.
198
- # So we assume that we're reporting a master pipeline if the project name is 'gitlab-qa'.
199
-
200
- Runtime::Env.pipeline_from_project_name
136
+ text.strip[/Error:(.*)/m, 1].to_s
201
137
  end
202
138
  end
203
139
  end