gitlab-qa 6.6.0 → 6.10.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -45,7 +45,7 @@ module Gitlab
45
45
  abort_not_permitted
46
46
  end
47
47
 
48
- def find_issues(iid:, options: {}, &select)
48
+ def find_issues(iid: nil, options: {}, &select)
49
49
  select ||= :itself
50
50
 
51
51
  handle_gitlab_client_exceptions do
@@ -57,15 +57,17 @@ module Gitlab
57
57
  end
58
58
  end
59
59
 
60
+ def find_issue_discussions(iid:)
61
+ handle_gitlab_client_exceptions do
62
+ Gitlab.issue_discussions(project, iid, order_by: 'created_at', sort: 'asc').auto_paginate
63
+ end
64
+ end
65
+
60
66
  def create_issue(title:, description:, labels:)
61
- puts "Creating issue..."
67
+ attrs = { description: description, labels: labels }
62
68
 
63
69
  handle_gitlab_client_exceptions do
64
- Gitlab.create_issue(
65
- project,
66
- title,
67
- { description: description, labels: labels }
68
- )
70
+ Gitlab.create_issue(project, title, attrs)
69
71
  end
70
72
  end
71
73
 
@@ -75,6 +77,18 @@ module Gitlab
75
77
  end
76
78
  end
77
79
 
80
+ def create_issue_note(iid:, note:)
81
+ handle_gitlab_client_exceptions do
82
+ Gitlab.create_issue_note(project, iid, note)
83
+ end
84
+ end
85
+
86
+ def add_note_to_issue_discussion_as_thread(iid:, discussion_id:, body:)
87
+ handle_gitlab_client_exceptions do
88
+ Gitlab.add_note_to_issue_discussion_as_thread(project, iid, discussion_id, body: body)
89
+ end
90
+ end
91
+
78
92
  def handle_gitlab_client_exceptions
79
93
  yield
80
94
  rescue Gitlab::Error::NotFound
@@ -95,6 +109,9 @@ module Gitlab
95
109
  pipeline = QA::Runtime::Env.pipeline_from_project_name
96
110
  channel = pipeline == "canary" ? "qa-production" : "qa-#{pipeline}"
97
111
  error_msg = warn_exception(e)
112
+
113
+ return unless QA::Runtime::Env.ci_commit_ref_name == 'master'
114
+
98
115
  slack_options = {
99
116
  channel: channel,
100
117
  icon_emoji: ':ci_failing:',
@@ -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,169 @@
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
+ unless relevant_issue_stacktrace
86
+ puts " => [DEBUG] Found issue #{issue.web_url} but stacktrace doesn't match."
87
+ next
88
+ end
89
+
90
+ distance = ld.call(first_test_failure_stacktrace, relevant_issue_stacktrace)
91
+ diff_ratio = (distance.to_f / first_test_failure_stacktrace.size).round(3)
92
+ if diff_ratio <= max_diff_ratio
93
+ memo[issue] = diff_ratio
94
+ else
95
+ puts " => [DEBUG] Found issue #{issue.web_url} but stacktrace is too different (#{(diff_ratio * 100).round(2)}%)."
96
+ end
97
+ end
98
+ end
99
+
100
+ def find_issue_stacktrace(issue)
101
+ issue_stacktrace_match = issue.description.match(STACKTRACE_REGEX)
102
+
103
+ if issue_stacktrace_match
104
+ issue_stacktrace_match[2].gsub(/^#.*$/, '').strip
105
+ else
106
+ puts "\n => Stacktrace couldn't be found for #{issue.web_url}:\n\n#{issue.description}\n\n----------------------------------\n"
107
+ end
108
+ end
109
+
110
+ def find_failure_issue(test)
111
+ relevant_issues = find_relevant_failure_issues(test)
112
+
113
+ return nil if relevant_issues.empty?
114
+
115
+ best_matching_issue, smaller_diff_ratio = relevant_issues.min_by { |_, diff_ratio| diff_ratio }
116
+
117
+ unless relevant_issues.values.count(smaller_diff_ratio) == 1 # rubocop:disable Style/IfUnlessModifier
118
+ raise(MultipleIssuesFound, %(Too many issues found for test '#{test.name}' (`#{test.file}`)!))
119
+ end
120
+
121
+ test.failure_issue ||= best_matching_issue.web_url
122
+
123
+ [best_matching_issue, smaller_diff_ratio]
124
+ end
125
+
126
+ def new_issue_description(test)
127
+ super + [
128
+ "\n\n### Stack trace",
129
+ "```\n#{test.failures.first['message_lines'].join("\n")}\n```",
130
+ "First happened in #{test.ci_job_url}."
131
+ ].join("\n\n")
132
+ end
133
+
134
+ def deploy_environment_label
135
+ environment = Runtime::Env.deploy_environment
136
+
137
+ case environment
138
+ when 'production'
139
+ 'found:gitlab.com'
140
+ when 'canary', 'staging'
141
+ "found:#{environment}.gitlab.com"
142
+ when 'preprod'
143
+ 'found:pre.gitlab.com'
144
+ when 'staging-orchestrated', 'nightly', 'master'
145
+ "found:#{environment}"
146
+ else
147
+ raise "No `found:*` label for the `#{environment}` environment!"
148
+ end
149
+ end
150
+
151
+ def new_issue_labels(test)
152
+ NEW_ISSUE_LABELS + up_to_date_labels(test: test)
153
+ end
154
+
155
+ def up_to_date_labels(test:, issue: nil)
156
+ super << deploy_environment_label
157
+ end
158
+
159
+ def post_failed_job_note(issue, test)
160
+ gitlab.create_issue_note(iid: issue.iid, note: "/relate #{test.testcase}")
161
+ end
162
+
163
+ def title_from_test(test)
164
+ "Failure in #{super}"
165
+ end
166
+ end
167
+ end
168
+ end
169
+ 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,14 @@ module Gitlab
24
28
  raise NotImplementedError
25
29
  end
26
30
 
31
+ def new_issue_description(test)
32
+ "### Full description\n\n#{search_safe(test.name)}\n\n### File path\n\n#{test.file}"
33
+ end
34
+
35
+ def new_issue_labels(test)
36
+ []
37
+ end
38
+
27
39
  def validate_input!
28
40
  assert_project!
29
41
  assert_input_files!(files)
@@ -41,6 +53,92 @@ module Gitlab
41
53
 
42
54
  abort "Please provide valid JUnit report files. No files were found matching `#{files.join(',')}`"
43
55
  end
56
+
57
+ def test_results_per_file
58
+ Dir.glob(files).each do |path|
59
+ extension = File.extname(path)
60
+
61
+ test_results =
62
+ case extension
63
+ when '.json'
64
+ Report::JsonTestResults.new(path)
65
+ when '.xml'
66
+ Report::JUnitTestResults.new(path)
67
+ else
68
+ raise "Unknown extension #{extension}"
69
+ end
70
+
71
+ yield test_results
72
+ end
73
+ end
74
+
75
+ def create_issue(test)
76
+ gitlab.create_issue(
77
+ title: title_from_test(test),
78
+ description: new_issue_description(test),
79
+ labels: new_issue_labels(test).to_a
80
+ )
81
+ end
82
+
83
+ def issue_labels(issue)
84
+ issue&.labels&.to_set || Set.new
85
+ end
86
+
87
+ def update_labels(issue, test)
88
+ new_labels = up_to_date_labels(test: test, issue: issue)
89
+
90
+ return if issue_labels(issue) == new_labels
91
+
92
+ gitlab.edit_issue(iid: issue.iid, options: { labels: new_labels.to_a })
93
+ end
94
+
95
+ def up_to_date_labels(test:, issue: nil)
96
+ labels = issue_labels(issue)
97
+ labels << "Enterprise Edition" if ee_test?(test)
98
+ quarantine_job? ? labels << "quarantine" : labels.delete("quarantine")
99
+
100
+ labels
101
+ end
102
+
103
+ def ee_test?(test)
104
+ test.file =~ %r{features/ee/(api|browser_ui)}
105
+ end
106
+
107
+ def quarantine_job?
108
+ Runtime::Env.ci_job_name&.include?('quarantine')
109
+ end
110
+
111
+ def partial_file_path(path)
112
+ path.match(/((api|browser_ui).*)/i)[1]
113
+ end
114
+
115
+ def title_from_test(test)
116
+ title = "#{partial_file_path(test.file)} | #{search_safe(test.name)}".strip
117
+
118
+ return title unless title.length > MAX_TITLE_LENGTH
119
+
120
+ "#{title[0...MAX_TITLE_LENGTH - 3]}..."
121
+ end
122
+
123
+ def search_safe(value)
124
+ value.delete('"')
125
+ end
126
+
127
+ def pipeline
128
+ # Gets the name of the pipeline the test was run in, to be used as the key of a scoped label
129
+ #
130
+ # Tests can be run in several pipelines:
131
+ # gitlab-qa, nightly, master, staging, canary, production, preprod, and MRs
132
+ #
133
+ # Some of those run in their own project, so CI_PROJECT_NAME is the name we need. Those are:
134
+ # nightly, staging, canary, production, and preprod
135
+ #
136
+ # MR, master, and gitlab-qa tests run in gitlab-qa, but we only want to report tests run on master
137
+ # because the other pipelines will be monitored by the author of the MR that triggered them.
138
+ # So we assume that we're reporting a master pipeline if the project name is 'gitlab-qa'.
139
+
140
+ @pipeline ||= Runtime::Env.pipeline_from_project_name
141
+ end
44
142
  end
45
143
  end
46
144
  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