gitlab-qa 6.7.0 → 6.11.0

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