gitlab-qa 6.4.0 → 6.8.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.
@@ -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')
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
@@ -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,139 @@
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
+ post_failed_job_note(issue, test)
51
+ puts " => Marked #{issue.web_url} as related to #{test.testcase}."
52
+ rescue MultipleIssuesFound => e
53
+ warn(e.message)
54
+ end
55
+ end
56
+
57
+ def find_or_create_issue(test)
58
+ issue, diff_ratio = find_failure_issue(test)
59
+
60
+ if issue
61
+ puts " => Found issue #{issue.web_url} for test '#{test.name}' with a diff ratio of #{(diff_ratio * 100).round(2)}%."
62
+ else
63
+ issue = create_issue(test)
64
+ puts " => Created new issue: #{issue.web_url} for test '#{test.name}'." if issue
65
+ end
66
+
67
+ issue
68
+ end
69
+
70
+ def failure_issues(test)
71
+ gitlab.find_issues(options: { state: 'opened', labels: 'QA' }).select do |issue|
72
+ issue_title = issue.title.strip
73
+ issue_title.include?(test.name) || issue_title.include?(partial_file_path(test.file))
74
+ end
75
+ end
76
+
77
+ def find_relevant_failure_issues(test) # rubocop:disable Metrics/AbcSize
78
+ ld = Class.new.extend(Gem::Text).method(:levenshtein_distance)
79
+ first_test_failure_stacktrace = test.failures.first['message_lines'].join("\n")
80
+
81
+ # Search with the `search` param returns 500 errors, so we filter by ~QA and then filter further in Ruby
82
+ failure_issues(test).each_with_object({}) do |issue, memo|
83
+ relevant_issue_stacktrace = find_issue_stacktrace(issue)
84
+ unless relevant_issue_stacktrace
85
+ puts " => [DEBUG] Found issue #{issue.web_url} but stacktrace doesn't match."
86
+ next
87
+ end
88
+
89
+ distance = ld.call(first_test_failure_stacktrace, relevant_issue_stacktrace)
90
+ diff_ratio = (distance.to_f / first_test_failure_stacktrace.size).round(3)
91
+ if diff_ratio <= max_diff_ratio
92
+ memo[issue] = diff_ratio
93
+ else
94
+ puts " => [DEBUG] Found issue #{issue.web_url} but stacktrace is too different (#{(diff_ratio * 100).round(2)}%)."
95
+ end
96
+ end
97
+ end
98
+
99
+ def find_issue_stacktrace(issue)
100
+ issue_stacktrace_match = issue.description.match(STACKTRACE_REGEX)
101
+
102
+ if issue_stacktrace_match
103
+ issue_stacktrace_match[2].gsub(/^#.*$/, '').strip
104
+ else
105
+ puts "\n => Stacktrace couldn't be found for #{issue.web_url}:\n\n#{issue.description}\n\n----------------------------------\n"
106
+ end
107
+ end
108
+
109
+ def find_failure_issue(test)
110
+ relevant_issues = find_relevant_failure_issues(test)
111
+
112
+ return nil if relevant_issues.empty?
113
+
114
+ best_matching_issue, smaller_diff_ratio = relevant_issues.min_by { |_, diff_ratio| diff_ratio }
115
+
116
+ unless relevant_issues.values.count(smaller_diff_ratio) == 1 # rubocop:disable Style/IfUnlessModifier
117
+ raise(MultipleIssuesFound, %(Too many issues found for test '#{test.name}' (`#{test.file}`)!))
118
+ end
119
+
120
+ test.failure_issue ||= best_matching_issue.web_url
121
+
122
+ [best_matching_issue, smaller_diff_ratio]
123
+ end
124
+
125
+ def new_issue_description(test)
126
+ super + "\n\n### Stack trace\n\n```\n#{test.failures.first['message_lines'].join("\n")}\n```"
127
+ end
128
+
129
+ def new_issue_labels(test)
130
+ NEW_ISSUE_LABELS + up_to_date_labels(test: test)
131
+ end
132
+
133
+ def post_failed_job_note(issue, test)
134
+ gitlab.create_issue_note(iid: issue.iid, note: "/relate #{test.testcase}")
135
+ end
136
+ end
137
+ end
138
+ end
139
+ 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)
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,31 +8,19 @@ 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
 
@@ -72,12 +60,14 @@ module Gitlab
72
60
  issues.first
73
61
  end
74
62
 
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
- )
63
+ def new_issue_labels(test)
64
+ %w[status::automated]
65
+ end
66
+
67
+ def up_to_date_labels(test:, issue: nil)
68
+ labels = super
69
+ labels.delete_if { |label| label.start_with?("#{pipeline}::") }
70
+ labels << (test.failures.empty? ? "#{pipeline}::passed" : "#{pipeline}::failed")
81
71
  end
82
72
 
83
73
  def iid_from_testcase_url(url)
@@ -85,23 +75,7 @@ module Gitlab
85
75
  end
86
76
 
87
77
  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('"')
78
+ %("#{partial_file_path(test.file)}" "#{search_safe(test.name)}")
105
79
  end
106
80
 
107
81
  def note_status(issue, test)
@@ -109,13 +83,11 @@ module Gitlab
109
83
 
110
84
  note = note_content(test)
111
85
 
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)
86
+ gitlab.find_issue_discussions(iid: issue.iid).each do |discussion|
87
+ 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
88
  end
89
+
90
+ gitlab.create_issue_note(iid: issue.iid, note: note)
119
91
  end
120
92
 
121
93
  def note_content(test)
@@ -143,10 +115,6 @@ module Gitlab
143
115
  summary.join(' ')
144
116
  end
145
117
 
146
- def quarantine_job?
147
- Runtime::Env.ci_job_name&.include?('quarantine')
148
- end
149
-
150
118
  def new_note_matches_discussion?(note, discussion)
151
119
  note_error = error_and_stack_trace(note)
152
120
  discussion_error = error_and_stack_trace(discussion.notes.first['body'])
@@ -163,42 +131,6 @@ module Gitlab
163
131
 
164
132
  result
165
133
  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
201
- end
202
134
  end
203
135
  end
204
136
  end