gitlab-qa 6.6.0 → 6.10.1

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.
@@ -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